sentry-test 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.4
2
+ Name: sentry-test
3
+ Version: 1.0.0
4
+ Summary: Test quality intelligence for change-oriented reviews
5
+ Author: Lucas Nicolau Ferreira
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Lucas Nicolau Ferreira
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALING IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Dynamic: license-file
32
+
33
+ # Sentry
34
+
35
+ CLI de qualidade de teste orientada a mudança. O `sentry` cria a pasta da spec, valida a matriz de casos em markdown, roda a suíte, lê o diff e a cobertura, e emite um veredito auditável — e deixa o fluxo escrito para o agente de IA que for implementar a mudança.
36
+
37
+ Markdown é a fonte da intenção: a CLI parseia e valida `CASES.md` e `PROMPT.md` — ela nunca os escreve. O agente de IA faz a redação; o Sentry garante estrutura, rastreabilidade e veredito.
38
+
39
+ > Não confundir com o [Sentry da getsentry](https://sentry.io) (monitoramento de erros). Este projeto é distribuído como `sentry-test`.
40
+
41
+ ## A divisão de responsabilidade
42
+
43
+ - **O agente de IA declara intenção** — escreve o `CASES.md`: requisito, camada, tipo, prioridade, entrada, resultado esperado.
44
+ - **O Sentry mede a realidade** — decide status, teste associado, evidência e veredito.
45
+
46
+ O agente nunca escreve status. O Sentry nunca chama um modelo. É isso que torna o veredito auditável e reproduzível.
47
+
48
+ ## Instalação
49
+
50
+ ```bash
51
+ pip install sentry-test
52
+ ```
53
+
54
+ Requer Python 3.11+. Confira com `sentry --version` (imprime a versão do pacote).
55
+
56
+ Se faltar `pytest` ou `coverage`, o `init` avisa. Para instalar junto:
57
+
58
+ ```bash
59
+ sentry init --install
60
+ ```
61
+
62
+ ## Setup
63
+
64
+ ```bash
65
+ cd seu-projeto
66
+ sentry init
67
+ ```
68
+
69
+ Cria `.sentry/` (specs, execuções, relatórios e banco), o `sentry.toml`, as entradas do `.gitignore`, e escreve o fluxo em dois lugares:
70
+
71
+ - **Skill `sentry-cases`** em `.claude/skills/sentry-cases/SKILL.md` — carregada automaticamente pelo Claude Code e por agentes que seguem essa convenção.
72
+ - **`AGENT-SENTRY.md`** na raiz do projeto — o mesmo fluxo em markdown puro, sem depender de convenção de nenhuma ferramenta.
73
+
74
+ Para outros agentes (Cursor, Windsurf, Codex, opencode), aponte-os para o `AGENT-SENTRY.md` no arquivo de regras que cada um já usa — por exemplo, uma linha em `.cursor/rules` ou `AGENTS.md`:
75
+
76
+ ```markdown
77
+ Para escrever ou revisar casos de teste, siga AGENT-SENTRY.md.
78
+ ```
79
+
80
+ `init` é idempotente: não apaga histórico, não sobrescreve configuração existente, não duplica estruturas.
81
+
82
+ ## O fluxo
83
+
84
+ 1. `sentry new "cadastro de cliente"` — cria `.sentry/specs/cadastro-de-cliente/` com `PROMPT.md` (pedido preservado) e `CASES.md` em branco
85
+ 2. **`sentry-cases`** — seu agente pergunta o que estiver ambíguo e preenche o `CASES.md` seguindo o template
86
+ 3. `sentry check cadastro-de-cliente` — estrutura válida? classes de equivalência do catálogo cobertas?
87
+ 4. seu agente liga cada caso ao teste real com o marcador `# cenario: <nome exato do caso>`
88
+ 5. `sentry run --spec cadastro-de-cliente --run-tests` — roda a suíte, lê diff e cobertura, aplica as regras, persiste
89
+ 6. `sentry report` / `sentry history` — releitura e comparação entre execuções
90
+
91
+ Todo passo também funciona sem agente, pelos comandos abaixo.
92
+
93
+ ## Comandos
94
+
95
+ Código de saída `0` em sucesso; veja a tabela de códigos adiante.
96
+
97
+ | Comando | O que faz |
98
+ | --- | --- |
99
+ | `sentry init [--install]` | Prepara o repositório: `.sentry/`, `sentry.toml`, `.gitignore`, guia de agente e skills. Com `--install`, instala as dependências ausentes. |
100
+ | `sentry new <nome> [--prompt "..."] [--json]` | Cria a pasta da spec com slug derivado do nome. `--json` emite template, vocabulário aceito e classes cobradas, para o agente consumir. |
101
+ | `sentry check [<slug>\|all]` | Valida `CASES.md`: estrutura, vocabulário e cobrança de classes de equivalência. `all` valida todas as specs juntas. |
102
+ | `sentry run [--spec <slug>\|all] [--run-tests]` | Executa a análise e persiste. Sem `--run-tests` não há cobertura, e o veredito tende a `inconclusivo`. |
103
+ | `sentry report` | Exibe o último relatório (`.sentry/reports/latest.md`). |
104
+ | `sentry history` | Lista execuções e compara as duas últimas: cobertura, testes, achados novos, resolvidos e persistentes. |
105
+ | `sentry clear [--keep-last N] [--yes]` | Poda execuções e relatórios antigos. Sem `--yes` apenas mostra o que sairia — apagar histórico é irreversível. Nunca toca em `.sentry/specs/`. |
106
+
107
+ ## Códigos de saída
108
+
109
+ Quatro estados distinguíveis, para separar "código mal testado" de "meu ambiente quebrou":
110
+
111
+ | Código | Significado |
112
+ | --- | --- |
113
+ | `0` | aprovado |
114
+ | `1` | aprovado com ressalvas |
115
+ | `2` | reprovado |
116
+ | `3` | inconclusivo ou erro de infraestrutura |
117
+
118
+ Erro de infraestrutura nunca produz veredito aprovado: uma suíte que não conseguiu rodar é diferente de uma suíte que reprovou.
119
+
120
+ `sentry check` mantém semântica própria: `0` estrutura válida, `1` erros estruturais, `2` não foi possível resolver a spec.
121
+
122
+ ## Skills
123
+
124
+ Geradas para cada agente configurado; o `AGENT-SENTRY.md` cobre os demais.
125
+
126
+ | Workflow | O que o agente faz |
127
+ | --- | --- |
128
+ | `sentry-cases` | Recebe o pedido em texto livre, cria a spec, **pergunta antes de escrever** toda ambiguidade que mude um caso, preenche o `CASES.md`, liga cada caso ao teste com `# cenario:` e roda `check` até fechar limpo. Nunca escreve status. |
129
+
130
+ ## Regras determinísticas
131
+
132
+ Dez regras, com severidade configurável por projeto.
133
+
134
+ | Regra | Severidade padrão | Dispara quando |
135
+ | --- | --- | --- |
136
+ | `test-failing` | crítica | a suíte tem teste falhando |
137
+ | `case-spec-invalid` | crítica | o `CASES.md` tem erro estrutural |
138
+ | `changed-code-uncovered` | alta | cobertura do código alterado é zero |
139
+ | `scenario-without-test` | alta | caso declarado sem teste associado |
140
+ | `error-path-without-test` | alta | `raise`/`throw` em linha alterada que nenhum teste executou |
141
+ | `missing-equivalence-class` | alta | classe exigida pelo catálogo que nenhum caso cobre |
142
+ | `coverage-below-threshold` | alta | cobertura do código alterado abaixo do limiar declarado |
143
+ | `requirement-without-scenario` | média | requisito sem cenário correspondente |
144
+ | `coverage-missing` | média | não foi possível calcular a cobertura do código alterado |
145
+ | `global-coverage-below-threshold` | média | cobertura global abaixo do limiar declarado |
146
+
147
+ Sem limiar declarado, o Sentry não inventa um mínimo. Quem define "quanto basta" é o projeto, e o relatório registra o número aplicado.
148
+
149
+ ## Catálogo de classes de equivalência
150
+
151
+ Tabela fixa de situações que precisam de teste, **por tipo de campo**. Não gera casos: cobra os que o agente deixou de declarar.
152
+
153
+ Tipos conhecidos: `cpf`, `cnpj`, `email`, `senha`, `data`, `telefone`, `cep`, `inteiro`, `decimal`, `texto`, `rota`.
154
+
155
+ Uma classe que não faz sentido para o campo pode ser dispensada **com justificativa**, em vez de virar caso artificial ou cobrança eterna:
156
+
157
+ ```markdown
158
+ ## Classes não aplicáveis
159
+
160
+ - **exclude/tamanho-maximo-excedido**: é parâmetro de configuração, não campo de formulário
161
+ ```
162
+
163
+ A dispensa remove o achado, mas fica registrada no relatório — nada some em silêncio.
164
+
165
+ ## Dimensões de cobertura
166
+
167
+ Cada uma reporta `coberta`, `parcial`, `não coberta` ou `não aplicável`, com evidência.
168
+
169
+ | Dimensão | De onde tira a evidência |
170
+ | --- | --- |
171
+ | requisitos e regras de negócio | cenários da spec com teste associado |
172
+ | APIs, persistência, transações e integrações | casos de tipo `contrato`/`integração` e camada `integração` |
173
+ | exceções, resiliência e recuperação | caminhos de erro alterados executados por algum teste |
174
+ | segurança e autorização | campos de tipo `rota` com todas as classes de acesso cobertas |
175
+
176
+ `não aplicável` é distinto de `não coberta`: um projeto sem rotas não é punido na dimensão de segurança.
177
+
178
+ ## Configuração
179
+
180
+ `sentry.toml` na raiz, versionável, sem segredos. Tudo é opcional além do que o `init` já escreve.
181
+
182
+ ```toml
183
+ [project]
184
+ name = "meu-projeto"
185
+
186
+ [specs]
187
+ path = ".sentry/specs"
188
+
189
+ [test] # qualquer executor que exporte JUnit XML
190
+ command = "npx jest"
191
+ junit_xml = "reports/junit.xml" # sem isto, o Sentry injeta --junitxml (pytest)
192
+
193
+ [tests] # onde procurar testes
194
+ paths = ["tests"] # padrão: tests, test, spec, __tests__
195
+
196
+ [coverage] # relatório gerado pela suíte do próprio projeto
197
+ path = "coverage/lcov.info"
198
+ format = "lcov" # opcional: detectado pelo conteúdo quando omitido
199
+
200
+ [analysis]
201
+ run_tests_by_default = false
202
+ timeout_seconds = 300
203
+ exclude = ["frontend/"] # diretórios fora do escopo da análise
204
+
205
+ [policy.thresholds] # sem isto, nenhum mínimo é cobrado
206
+ changed_coverage = 85
207
+ global_coverage = 90
208
+
209
+ [policy.severities] # sobrescreve a severidade de qualquer regra
210
+ coverage-missing = "alta"
211
+
212
+ [catalog.fields] # tipos de campo do seu domínio
213
+ matricula = ["vazio", "formato-invalido", "valida"]
214
+
215
+ [dimensions] # eixos que não se aplicam ao projeto
216
+ disabled = []
217
+ ```
218
+
219
+ `.sentry/` guarda specs, execuções, relatórios e o banco. Fica fora do Git, com uma exceção deliberada: `.sentry/reports/latest.md` é versionado, para o veredito aparecer no diff da PR sem que o revisor precise rodar o Sentry.
220
+
221
+ O histórico é mantido indefinidamente, e cresce a cada execução. Para podar:
222
+
223
+ ```bash
224
+ sentry clear --keep-last 10 # mostra o que sairia
225
+ sentry clear --keep-last 10 --yes # remove
226
+ ```
227
+
228
+ As specs nunca são removidas: são intenção declarada, não evidência gerada.
229
+
230
+ ## Stacks suportadas
231
+
232
+ A **derivação** — do pedido à matriz de casos — é agnóstica de linguagem: o `CASES.md` é markdown e o catálogo raciocina sobre tipo de dado, não sobre código.
233
+
234
+ A **verificação** depende do formato de intercâmbio que sua suíte exporta, não da ferramenta:
235
+
236
+ | Capacidade | Suporte |
237
+ | --- | --- |
238
+ | Execução da suíte | qualquer comando que exporte **JUnit XML** — pytest, Jest, Vitest, `go test` (gotestsum), Surefire, `dotnet test`, RSpec, PHPUnit |
239
+ | Cobertura | **lcov** (nyc, c8, Jest, simplecov), **Cobertura XML** (JaCoCo, coverlet), **coverage.py** (JSON) — detectados pelo conteúdo |
240
+ | Rastreabilidade caso↔teste | `.py`, `.js`/`.jsx`/`.ts`/`.tsx`, `.go`, `.java`/`.kt`, `.cs`, `.rb`, `.php`, `.rs` — e o marcador `cenario:` funciona em qualquer comentário |
241
+ | Caminhos de erro | por AST em Python; por padrão sintático (`throw`, `catch`, `panic`, `rescue`, `panic!`) nas demais |
242
+ | Análise de impacto | 12 extensões de código-fonte |
243
+
244
+ A detecção de caminho de erro fora de Python é menos precisa que AST, e o relatório registra essa diferença como limitação — nunca a esconde.
245
+
246
+ Camada `frontend` é recusada de propósito: sem adaptador que a verifique, um caso declarado ficaria preso em `não coberto` para sempre.
247
+
248
+ ## Local-first
249
+
250
+ Nenhuma telemetria, nenhuma chamada externa, nenhum envio de código ou diff. Todo o histórico permanece na máquina.
251
+
252
+ ## Desenvolvimento
253
+
254
+ ```bash
255
+ python -m pip install -e .
256
+ python -m pytest
257
+ ```
258
+
259
+ O Sentry se analisa: `sentry run --spec all --run-tests` na raiz do repositório
260
+ casa os casos declarados em `.sentry/specs/` com as funções de teste reais e
261
+ reporta as quatro dimensões.
262
+
263
+ ## Licença
264
+
265
+ MIT
@@ -0,0 +1,29 @@
1
+ sentry_test-1.0.0.dist-info/licenses/LICENSE,sha256=w2VXTcpLsShFLnGAOyIAXTJgzh1fE35Pc0tzeld0VDM,1078
2
+ sentrytest/__init__.py,sha256=35jw87a9NAUeOFJnDPpzOjaRE4xAjG2Qrskk9ZJGNRA,63
3
+ sentrytest/cli.py,sha256=J2_2QV8N00D-My9x8x-kelVUN5zwiotExBFTwMaldbE,11113
4
+ sentrytest/init_project.py,sha256=ty-PO5FEW9UvfiC0KdcKVtgOOlUvIx5HRAHn4cEoitQ,4745
5
+ sentrytest/skills.py,sha256=__0X6EGxuABByhULfRyhn37IRH2-LM1Vmib0cu6s0h4,8981
6
+ sentrytest/adapters/__init__.py,sha256=sHs7IzwyJBkCa8MSKMO5_lbcmxZxgYbQt0DTRFoOjGU,45
7
+ sentrytest/adapters/case_specs.py,sha256=RiO6Uqtbga09Liuxnwqu9SYEhjTM7TIC_cqC52GxP6k,8795
8
+ sentrytest/adapters/local_tools.py,sha256=IAFIQaatl8Lt2GPJr8bx0NulFztuxOQdTOUdYAsTyBU,16006
9
+ sentrytest/adapters/toml_config.py,sha256=hnvpaM_2ksNeckjbf_QyTYTvqrjvOi-B7dlCr6T7fbg,248
10
+ sentrytest/application/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ sentrytest/application/analyze.py,sha256=b0UhDuOI-1b85a8tIph9VjuKyMu9TpTqwJ8NszYO4w0,13931
12
+ sentrytest/application/cases.py,sha256=t_gn5g4DXvE_vECwdJlK0El5tDsaYRa6ORlo3hZv-OQ,4780
13
+ sentrytest/application/coverage_context.py,sha256=nnoANIM9Y1P1FVYXYh1_ZS2if5nyRavSu1ugravRBH8,931
14
+ sentrytest/application/dimensions.py,sha256=QNtrklzlneKMcFcoCgiXvhK0qUr9TPDtHNY6thRbKJo,4272
15
+ sentrytest/application/error_paths.py,sha256=HetpiqzTcoldOL0q9ezBJ1ZOpJFxu89KmreNSXhn4pI,5478
16
+ sentrytest/application/impact.py,sha256=Hxdaw2Sjxdas-J5OaaTy6GghjrFkRhgeZbniRiy0l5Q,3487
17
+ sentrytest/application/reporting.py,sha256=GKia_LmG62kUCxsPhRkq8049qRTlxAFTYYnD3BHHIWQ,11022
18
+ sentrytest/application/traceability.py,sha256=ZjYbhQSQhYRwdDCEO7HCf122PEGVQGha0vGEwfrTerY,8070
19
+ sentrytest/domain/__init__.py,sha256=SRfF7oDVlOOAi6nGKiJIUK6B_arqYLO9iSMp-2IZZps,21
20
+ sentrytest/domain/catalog.py,sha256=v9XDZOHsTa7L2r_B1gI992FGoq_OAIvg3FJZV09DSlc,3949
21
+ sentrytest/domain/models.py,sha256=BsEP65y8o-Maa1jhHhFaWVkLTzOxY4w_mVuyOCV1MXs,3831
22
+ sentrytest/domain/rules.py,sha256=7t4I6CdbxUyjB_USDS4i8D1Ps7xlbiEK4iAgotBKe3w,5757
23
+ sentrytest/ports/__init__.py,sha256=PN4XmYk6B1tg4awK-1AC-tKDBGwUtDPHH3XP1mYGzDA,21
24
+ sentrytest/ports/inputs.py,sha256=fXJTV8RypTjTNmkZ27BcBLMwP7qKtxMg0tcGWYHxTD8,1850
25
+ sentry_test-1.0.0.dist-info/METADATA,sha256=eyeX-ZIK44me6QKOn8dk1AhM13GNu6Zko1QKaRtJd4M,12686
26
+ sentry_test-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
27
+ sentry_test-1.0.0.dist-info/entry_points.txt,sha256=fgtkXdY-icVXreQV1exA2Qrqv5Qx5CBIdc0XHjAPCHY,47
28
+ sentry_test-1.0.0.dist-info/top_level.txt,sha256=_tdeT-T3OV7Hfd-jq_AkbutODFLUJUfboZtCML-Qs9s,11
29
+ sentry_test-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sentry = sentrytest.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Nicolau Ferreira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALING IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sentrytest
sentrytest/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Núcleo público do pacote Sentry."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1 @@
1
+ """Adaptadores das integrações externas."""
@@ -0,0 +1,244 @@
1
+ """Leitura e validação da matriz de casos declarada em CASES.md.
2
+
3
+ O agente de IA escreve o arquivo seguindo TEMPLATE; o Sentry apenas parseia e
4
+ valida a estrutura. Nenhuma inferência é feita sobre o conteúdo em texto livre.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import unicodedata
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ TEMPLATE = """# <Título da funcionalidade>
14
+
15
+ ## Prompt
16
+
17
+ <o pedido original em texto livre, preservado literalmente>
18
+
19
+ ## Campos
20
+
21
+ - **<nome do campo>**: <tipo> — <regra declarada>
22
+
23
+ ## Caso: <nome curto e único>
24
+
25
+ - **Requisito:** <o que do prompt este caso verifica>
26
+ - **Camada:** backend | integração
27
+ - **Tipo:** unitário | integração | contrato
28
+ - **Prioridade:** crítica | alta | média | baixa
29
+ - **Classe:** <campo>/<classe de equivalência>
30
+ - **Dado:** <pré-condição>
31
+ - **Quando:** <ação>
32
+ - **Então:** <resultado esperado>
33
+ - **Entrada:** `campo = valor`
34
+
35
+ ## Classes não aplicáveis
36
+
37
+ - **<campo>/<classe>**: <motivo pelo qual esta classe do catálogo não se aplica aqui>
38
+ """
39
+
40
+ LAYERS = ("backend", "integração")
41
+ TEST_TYPES = ("unitário", "integração", "contrato")
42
+ PRIORITIES = ("crítica", "alta", "média", "baixa")
43
+
44
+ _TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
45
+ _SECTION = re.compile(r"^##\s+(.+?)\s*\n([\s\S]*?)(?=\n##\s|\Z)", re.MULTILINE)
46
+ _FIELD = re.compile(r"^-\s+\*\*(.+?)\*\*:\s*(\S+)\s*(?:[—-]\s*(.*))?$", re.MULTILINE)
47
+ _NOT_APPLICABLE = re.compile(r"^-\s+\*\*(.+?)/(.+?)\*\*:\s*(.*?)\s*$", re.MULTILINE)
48
+ _BULLET = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*)$", re.MULTILINE)
49
+ _INPUT_PAIR = re.compile(r"([A-Za-z_][\w.]*)\s*=\s*(\"[^\"]*\"|'[^']*'|[^,]+)")
50
+
51
+ _REQUIRED_BULLETS = ("requisito", "camada", "tipo", "prioridade", "dado", "quando", "entao")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FieldSpec:
56
+ name: str
57
+ type: str
58
+ rule: str = ""
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CaseSpec:
63
+ name: str
64
+ requirement: str
65
+ layer: str
66
+ test_type: str
67
+ priority: str
68
+ given: str
69
+ when: str
70
+ then: str
71
+ equivalence_class: str | None = None
72
+ input_data: dict[str, str] = None
73
+
74
+ def __post_init__(self):
75
+ if self.input_data is None:
76
+ object.__setattr__(self, "input_data", {})
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class NotApplicableClass:
81
+ field: str
82
+ class_name: str
83
+ reason: str
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class CaseDocument:
88
+ title: str
89
+ prompt: str
90
+ fields: tuple[FieldSpec, ...] = ()
91
+ cases: tuple[CaseSpec, ...] = ()
92
+ not_applicable: tuple[NotApplicableClass, ...] = ()
93
+
94
+
95
+ def _normalize(text: str) -> str:
96
+ stripped = unicodedata.normalize("NFKD", text.strip().casefold())
97
+ return "".join(char for char in stripped if not unicodedata.combining(char))
98
+
99
+
100
+ def _match_enum(value: str, allowed: tuple[str, ...]) -> str | None:
101
+ normalized = _normalize(value)
102
+ for candidate in allowed:
103
+ if _normalize(candidate) == normalized:
104
+ return candidate
105
+ return None
106
+
107
+
108
+ def _parse_input_data(raw: str) -> dict[str, str]:
109
+ content = raw.strip().strip("`").strip()
110
+ if not content:
111
+ return {}
112
+ result: dict[str, str] = {}
113
+ for name, value in _INPUT_PAIR.findall(content):
114
+ cleaned = value.strip()
115
+ if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in "\"'":
116
+ cleaned = cleaned[1:-1]
117
+ result[name] = cleaned
118
+ return result
119
+
120
+
121
+ def _parse_fields(body: str) -> tuple[FieldSpec, ...]:
122
+ fields = []
123
+ for name, type_name, rule in _FIELD.findall(body):
124
+ name = name.strip()
125
+ if not name or name.startswith("<"):
126
+ continue
127
+ fields.append(FieldSpec(name, type_name.strip(), (rule or "").strip()))
128
+ return tuple(fields)
129
+
130
+
131
+ def _parse_not_applicable(body: str) -> tuple[NotApplicableClass, ...]:
132
+ items = []
133
+ for field_name, class_name, reason in _NOT_APPLICABLE.findall(body):
134
+ field_name, class_name, reason = field_name.strip(), class_name.strip(), reason.strip()
135
+ if not field_name or field_name.startswith("<"):
136
+ continue
137
+ items.append(NotApplicableClass(field_name, class_name, reason))
138
+ return tuple(items)
139
+
140
+
141
+ def _parse_case(name: str, body: str) -> CaseSpec:
142
+ bullets = {_normalize(key): value.strip() for key, value in _BULLET.findall(body)}
143
+ return CaseSpec(
144
+ name=name,
145
+ requirement=bullets.get("requisito", ""),
146
+ layer=bullets.get("camada", ""),
147
+ test_type=bullets.get("tipo", ""),
148
+ priority=bullets.get("prioridade", ""),
149
+ given=bullets.get("dado", ""),
150
+ when=bullets.get("quando", ""),
151
+ then=bullets.get("entao", ""),
152
+ equivalence_class=bullets.get("classe") or None,
153
+ input_data=_parse_input_data(bullets.get("entrada", "")),
154
+ )
155
+
156
+
157
+ def parse_cases(text: str) -> CaseDocument:
158
+ title_match = _TITLE.search(text)
159
+ title = title_match.group(1).strip() if title_match else ""
160
+ prompt = ""
161
+ fields: tuple[FieldSpec, ...] = ()
162
+ not_applicable: tuple[NotApplicableClass, ...] = ()
163
+ cases = []
164
+ for heading, body in _SECTION.findall(text):
165
+ heading = heading.strip()
166
+ normalized = _normalize(heading)
167
+ if normalized == "prompt":
168
+ prompt = body.strip()
169
+ elif normalized == "campos":
170
+ fields = _parse_fields(body)
171
+ elif normalized == "classes nao aplicaveis":
172
+ not_applicable = _parse_not_applicable(body)
173
+ elif normalized.startswith("caso:"):
174
+ name = heading.split(":", 1)[1].strip()
175
+ if name and not name.startswith("<"):
176
+ cases.append(_parse_case(name, body))
177
+ return CaseDocument(title=title, prompt=prompt, fields=fields, cases=tuple(cases), not_applicable=not_applicable)
178
+
179
+
180
+ def validate_document(document: CaseDocument) -> list[str]:
181
+ """Erros estruturais do CASES.md. Lista vazia significa documento válido."""
182
+ errors: list[str] = []
183
+ if not document.title or document.title.startswith("<"):
184
+ errors.append("título ausente: a primeira linha deve ser '# <Título da funcionalidade>'")
185
+ if not document.prompt or document.prompt.startswith("<"):
186
+ errors.append("seção '## Prompt' ausente ou não preenchida")
187
+ if not document.cases:
188
+ errors.append("nenhum caso declarado: use '## Caso: <nome>' para cada caso de teste")
189
+
190
+ seen: set[str] = set()
191
+ declared_fields = {field.name for field in document.fields}
192
+ for case in document.cases:
193
+ key = _normalize(case.name)
194
+ if key in seen:
195
+ errors.append(f"caso duplicado: '{case.name}'")
196
+ seen.add(key)
197
+
198
+ bullets = {
199
+ "requisito": case.requirement, "camada": case.layer, "tipo": case.test_type,
200
+ "prioridade": case.priority, "dado": case.given, "quando": case.when, "entao": case.then,
201
+ }
202
+ for bullet in _REQUIRED_BULLETS:
203
+ if not bullets[bullet]:
204
+ errors.append(f"caso '{case.name}': campo obrigatório ausente '{bullet}'")
205
+
206
+ if case.layer and not _match_enum(case.layer, LAYERS):
207
+ errors.append(f"caso '{case.name}': camada inválida '{case.layer}' (use: {', '.join(LAYERS)})")
208
+ if case.test_type and not _match_enum(case.test_type, TEST_TYPES):
209
+ errors.append(f"caso '{case.name}': tipo inválido '{case.test_type}' (use: {', '.join(TEST_TYPES)})")
210
+ if case.priority and not _match_enum(case.priority, PRIORITIES):
211
+ errors.append(f"caso '{case.name}': prioridade inválida '{case.priority}' (use: {', '.join(PRIORITIES)})")
212
+
213
+ if case.equivalence_class and "/" in case.equivalence_class:
214
+ field_name = case.equivalence_class.split("/", 1)[0].strip()
215
+ if declared_fields and field_name not in declared_fields:
216
+ errors.append(
217
+ f"caso '{case.name}': classe referencia o campo '{field_name}', que não está declarado em '## Campos'"
218
+ )
219
+
220
+ for item in document.not_applicable:
221
+ if not item.reason:
222
+ errors.append(f"classe não aplicável '{item.field}/{item.class_name}': falta o motivo")
223
+ if declared_fields and item.field not in declared_fields:
224
+ errors.append(
225
+ f"classe não aplicável referencia o campo '{item.field}', que não está declarado em '## Campos'"
226
+ )
227
+ return errors
228
+
229
+
230
+ class CaseSpecAdapter:
231
+ """Implementa a porta SpecReader lendo a matriz declarada em CASES.md."""
232
+
233
+ def __init__(self, path: Path):
234
+ self.path = path
235
+
236
+ def document(self) -> CaseDocument:
237
+ return parse_cases(self.path.read_text(encoding="utf-8"))
238
+
239
+ def scenarios(self):
240
+ from ..ports.inputs import SpecScenario
241
+ return tuple(
242
+ SpecScenario(case.name, case.given, case.when, case.then)
243
+ for case in self.document().cases
244
+ )