@payconductor/houndsman 0.1.0 → 0.1.1

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.
Files changed (45) hide show
  1. package/README.md +39 -78
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +89 -9
  6. package/src/core/assets.ts +8 -0
  7. package/src/core/brief.ts +25 -2
  8. package/src/core/commands/findings.ts +390 -0
  9. package/src/core/commands/scaffold.ts +20 -0
  10. package/src/core/config.ts +42 -0
  11. package/src/core/diff-source.ts +140 -0
  12. package/src/core/file-classifier.ts +50 -2
  13. package/src/core/finding-anchor.ts +147 -0
  14. package/src/core/findings-state.ts +538 -0
  15. package/src/core/jev/jev-client.ts +72 -11
  16. package/src/core/jev/jev-detector.ts +21 -19
  17. package/src/core/orchestrator.ts +169 -17
  18. package/src/core/paths.ts +3 -0
  19. package/src/core/registry-check.ts +15 -0
  20. package/src/core/report-builder.ts +113 -9
  21. package/src/core/types.ts +10 -0
  22. package/src/index.ts +78 -2
  23. package/src/server/artifacts.ts +66 -0
  24. package/src/server/client/App.tsx +158 -0
  25. package/src/server/client/ArtifactsPanel.tsx +48 -0
  26. package/src/server/client/Filters.tsx +83 -0
  27. package/src/server/client/FindingCard.tsx +122 -0
  28. package/src/server/client/FindingsList.tsx +54 -0
  29. package/src/server/client/RunList.tsx +43 -0
  30. package/src/server/client/SummaryBar.tsx +21 -0
  31. package/src/server/client/api.ts +37 -0
  32. package/src/server/client/constants.ts +63 -0
  33. package/src/server/client/index.tsx +12 -0
  34. package/src/server/client/types.ts +48 -0
  35. package/src/server/client-build.ts +47 -0
  36. package/src/server/index.ts +365 -0
  37. package/src/skill/README.md +14 -6
  38. package/src/skill/SETUP.md +5 -3
  39. package/src/skill/houndsman-update.md +1 -1
  40. package/src/skill/houndsman.md +87 -10
  41. package/src/tests/findings-anchor.test.ts +120 -0
  42. package/src/tests/findings-commands.test.ts +226 -0
  43. package/src/tests/findings-state.test.ts +274 -0
  44. package/src/tests/review-scripts.test.ts +299 -0
  45. package/src/tests/server.test.ts +212 -0
@@ -225,7 +225,7 @@ todos os itens — em especial: **geral/reutilizável** (não regra de negócio
225
225
  ## Verificação (obrigatória)
226
226
 
227
227
  ```bash
228
- bun run houndsman doctor # config sem drift + ambiente
228
+ bun run houndsman doctor # config sem drift + ambiente + credencial Jev
229
229
  bun run houndsman test # suíte da lib (project-agnóstica)
230
230
  bun run ci:fix # formatação/imports/lint (no repo da lib)
231
231
  bun run ci:check # tipagem (no repo da lib)
@@ -79,16 +79,31 @@ Gera em `.houndsman/.runs/<branch-slug>/`:
79
79
 
80
80
  | Artefato | Conteúdo |
81
81
  |---|---|
82
- | `classification.json` | Watchdogs ativados por arquivo + status (A/M/D/R) |
82
+ | `classification.json` | Watchdogs ativados por arquivo + status (A/M/D/R) + churn |
83
83
  | `candidates.json` | Indícios da pré-análise Jev (regra + arquivo + confiança) |
84
- | `briefs/<watchdog>.md` | Instruções prontas para cada subagente |
85
- | `manifest.json` | Resumo: watchdogs, arquivos, deletados, status, contexto, Jev |
84
+ | `briefs/<watchdog>.md` | Instruções prontas para cada subagente (arquivos por risco) |
85
+ | `manifest.json` | Resumo: watchdogs, arquivos, deletados, status, contexto, Jev, orçamento |
86
86
 
87
87
  **Excluídos automaticamente:** lock files, `dist/`, `build/`, `.env*`, imagens, source
88
88
  maps (padrões do projeto em `.houndsman/exclusions.ts`).
89
89
  **Deletados:** não entram como conteúdo; vão para o brief de watchdogs com
90
90
  `breakingChange` como candidatos a **breaking change**.
91
91
 
92
+ ### Orçamento por brief (PRs grandes)
93
+
94
+ O motor coleta o diff **uma única vez** (`git diff` + `git diff --numstat`) e classifica
95
+ com **churn** (linhas adicionadas/removidas) por arquivo — o custo não cresce com o nº de
96
+ arquivos da PR. Cada brief recebe no máximo `budgets.maxFilesPerBrief` arquivos (default 40),
97
+ **priorizados pelo maior churn**:
98
+
99
+ - O excedente vai para a seção **Fora do orçamento (NÃO revisar)** do brief e o watchdog deve
100
+ registrá-lo em `skipped` — a tabela de **Cobertura** mostra o que ficou de fora (nunca some
101
+ silenciosamente).
102
+ - Isso mantém o contexto do subagente limitado mesmo num PR de 10k linhas com centenas de
103
+ arquivos num único domínio.
104
+ - Ajuste em `houndsman.config.ts`: `budgets: { maxFilesPerBrief: 60 }` e
105
+ `concurrency: { jev: 8 }`. Valores não-positivos caem no default (o `doctor` avisa).
106
+
92
107
  ### Pré-análise Jev (Passo 1.5, opcional)
93
108
 
94
109
  Se `TYPESAFE_API_KEY` estiver definida, cada arquivo é avaliado pelo modelo **Jev** contra
@@ -96,14 +111,15 @@ as regras atômicas dos watchdogs. Respostas acima do limiar viram **candidatos*
96
111
  `candidates.json` e entram no brief como *warm start* — o subagente **confirma ou refuta**.
97
112
 
98
113
  - **Não é achado**: candidatos não bloqueiam.
99
- - **Falha-aberto**: sem chave / erro / timeout → `available:false`; a revisão segue idêntica.
114
+ - **Opcional**: sem chave / erro / timeout → `available:false`; a revisão segue idêntica.
100
115
  - Desativar: `houndsman --no-jev`.
101
116
 
102
117
  ---
103
118
 
104
119
  ## Passo 2 — Revisar (skill despacha os subagentes)
105
120
 
106
- Um subagente por brief, todos no mesmo bloco de tool calls. O prompt é o próprio brief:
121
+ Um subagente por brief. Despache em **ondas** (ex.: até 8 subagentes por bloco de tool
122
+ calls) — evita estourar limites de taxa quando há muitos watchdogs. O prompt é o próprio brief:
107
123
 
108
124
  ```
109
125
  Leia e execute o brief: .houndsman/.runs/<branch-slug>/briefs/<watchdog-id>.md
@@ -111,7 +127,8 @@ Você é o watchdog <watchdog-id> do {{projectName}}.
111
127
  ```
112
128
 
113
129
  Cada subagente grava `findings/<watchdog-id>.json` (schema `.houndsman/core/skill/OUTPUT-FORMAT.md`).
114
- Sem achados → `"findings": []` (arquivo obrigatório).
130
+ Sem achados → `"findings": []` (arquivo obrigatório). Arquivos na seção **Fora do orçamento**
131
+ não são lidos: registre-os em `skipped`.
115
132
 
116
133
  **NUNCA enviar:** `git diff` completo · histórico de commits · arquivos de outros domínios ·
117
134
  `AGENTS.md` completo (já está no contexto global).
@@ -130,6 +147,50 @@ Falha com mensagem clara se faltar algum `findings/<watchdog>.json`. Quando todo
130
147
  3. Agrupa por severidade: ❌ blocking > ⚠️ attention > 💡 suggestion > 🔎 nit
131
148
  4. Grava `.houndsman/.runs/<branch-slug>/review.md` + tabela de cobertura
132
149
  5. Anexa os candidatos Jev não confirmados em `🔎 Candidatos não verificados` (não bloqueiam)
150
+ 6. Agrupa os achados **por arquivo** dentro de cada severidade (navegabilidade em PRs grandes)
151
+ 7. Grava um `review.<watchdog>.md` por watchdog com achados e indexa no `review.md`
152
+ (**Relatórios por domínio**) — mantém o relatório consolidado enxuto
153
+ 8. Grava `.houndsman/.runs/<branch-slug>/state.json` — a **triagem**: um `id` curto e um
154
+ `fingerprint` de conteúdo por finding (Passo 4), preservados entre execuções
155
+
156
+ > Compatível com `classification.json` antigo: se faltar o churn, o orçamento não é aplicado.
157
+
158
+ ---
159
+
160
+ ## Passo 4 — Triagem (opcional)
161
+
162
+ Cada finding recebe um `id` curto e um `fingerprint` de **conteúdo** no `--finalize`,
163
+ gravados em `.houndsman/.runs/<branch-slug>/state.json`. A triagem é feita pela UI ou
164
+ pelo agente — sem editar o JSON à mão. Duas dimensões independentes:
165
+
166
+ - **decision:** `pending` · `execute` · `ignore` (+ `comment` opcional).
167
+ - **execState:** `pending` · `in_progress` · `done` · `stale`.
168
+
169
+ ### UI local
170
+
171
+ ```bash
172
+ houndsman server --open
173
+ ```
174
+
175
+ Abre a UI (Bun.serve + Preact): lista as runs, triar os findings por severidade, comentar,
176
+ marcar como feito e ler os artefatos (`manifest.json`, `classification.json`, `review.md`).
177
+ Bind em `127.0.0.1` (ferramenta local, sem auth).
178
+
179
+ ### Auto-fix pelo agente
180
+
181
+ ```bash
182
+ houndsman findings list --decision execute --json # pendentes marcados p/ corrigir
183
+ # ... o agente aplica as correções ...
184
+ houndsman findings sync # reancora por conteúdo
185
+ houndsman findings done <id> # confirma os concluídos
186
+ ```
187
+
188
+ > **A linha não é a identidade.** O auto-fix desloca linhas; o motor reancora cada finding
189
+ > pelo **conteúdo** (trecho + contexto) e não perde itens que se movem. Se a âncora some
190
+ > (o trecho foi corrigido), o finding vira `done` automaticamente quando a decisão era
191
+ > `execute`, ou `stale` caso contrário.
192
+ > Ocorrências byte-idênticas (mesmo trecho E mesmo contexto) compartilham o id — são
193
+ > tratadas como um grupo (`×N`); limitação conhecida.
133
194
 
134
195
  ---
135
196
 
@@ -150,10 +211,17 @@ Falha com mensagem clara se faltar algum `findings/<watchdog>.json`. Quando todo
150
211
  - ...
151
212
 
152
213
  ## ❌ blocking
153
- - **<regra>** — `arquivo:linha` · _<watchdogs>_
214
+ ### `src/routes/order-route.ts`
215
+ - **<regra>** — `src/routes/order-route.ts:5` · _<watchdogs>_
154
216
  <mensagem>
155
217
  **Ação:** <ação>
156
218
 
219
+ ## 🔎 Candidatos não verificados (Jev) ← se houver
220
+
221
+ ## Relatórios por domínio
222
+ | Watchdog | Achados | Revisados | Relatório |
223
+ |---|---|---|---|
224
+
157
225
  ## Cobertura
158
226
  | Watchdog | Arquivos revisados | Findings |
159
227
  |---|---|---|
@@ -175,7 +243,9 @@ git diff --name-status (base...head)
175
243
  ↓
176
244
  [2] N subagentes em paralelo ──► findings/<watchdog>.json
177
245
  ↓
178
- [3] houndsman --finalize ──► review.md
246
+ [3] houndsman --finalize ──► review.md + state.json
247
+ ↓
248
+ [4] triagem (UI ou agente) ──► state.json (decision/execState)
179
249
  ```
180
250
 
181
251
  ---
@@ -197,9 +267,11 @@ Se a branch não for informada, detecta com `git branch --show-current`. Base do
197
267
  | `houndsman` | Passo 1 — preparar (briefs + manifesto) |
198
268
  | `houndsman --finalize` | Passo 3 — gerar o relatório |
199
269
  | `houndsman watchdog <id>` | Contexto de um watchdog (`--json`, `--rules`) |
270
+ | `houndsman findings <sub>` | Triagem dos findings (list/show/set/done/reset/sync/summary/path) |
271
+ | `houndsman server` | UI local de triagem + leitura de artefatos (`--port`, `--host`, `--open`) |
200
272
  | `houndsman setup` | Cria a configuração inicial do projeto |
201
273
  | `houndsman update` | Sincroniza os arquivos auto-gerados (`.houndsman/core`) |
202
- | `houndsman doctor` | Valida a configuração e o ambiente (anti-drift) |
274
+ | `houndsman doctor` | Valida a configuração, o ambiente e a credencial Jev |
203
275
  | `houndsman test` | Suíte de testes da lib |
204
276
 
205
277
  ---
@@ -208,6 +280,7 @@ Se a branch não for informada, detecta com `git branch --show-current`. Base do
208
280
 
209
281
  - ❌ Enviar git diff completo para qualquer watchdog
210
282
  - ❌ Ativar watchdogs sem arquivos no seu domínio
283
+ - ❌ Revisar arquivos marcados como **Fora do orçamento** (registre-os em `skipped`)
211
284
  - ❌ Carregar documentação que o watchdog não precisa
212
285
  - ❌ Revisar arquivos gerados (lock files, builds, migrations)
213
286
  - ❌ Escrever markdown livre em vez do JSON de `OUTPUT-FORMAT.md`
@@ -225,7 +298,7 @@ Se a branch não for informada, detecta com `git branch --show-current`. Base do
225
298
  > skill **`houndsman-update`** (`src/skill/houndsman-update.md`).
226
299
  > Regra de ouro: só é regra o que o projeto **aplica hoje** — prove com evidência no código
227
300
  > (regra negativa com 0 ocorrências = seguida → **manter**).
228
- > Após qualquer alteração, rodar `houndsman doctor` (anti-drift) + `houndsman test` + `ci:fix` + `ci:check`.
301
+ > Após qualquer alteração, rodar `houndsman doctor` + `houndsman test` + `ci:fix` + `ci:check`.
229
302
 
230
303
  ---
231
304
 
@@ -237,3 +310,7 @@ Se a branch não for informada, detecta com `git branch --show-current`. Base do
237
310
  | Médio (5k linhas) | 4-7 | ~1-2 min |
238
311
  | Grande (50k linhas) | 7-10 | ~3-5 min |
239
312
  | Enorme (300k linhas) | 10-15 | ~5-8 min |
313
+
314
+ > O custo de coleta é **constante** (2 execuções de `git`), independente do nº de arquivos.
315
+ > Em PRs muito grandes, o limitante é o nº de subagentes (ondas) e o orçamento por brief —
316
+ > não o diff. Ajuste `budgets.maxFilesPerBrief` para revisar mais/menos arquivos por domínio.
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildAnchor,
4
+ fingerprintOf,
5
+ locateAnchor,
6
+ normalizeLine,
7
+ reanchor,
8
+ } from "../core/finding-anchor";
9
+
10
+ const DUPLICATED = [
11
+ "function a() {",
12
+ '\tconsole.log("x");',
13
+ "}",
14
+ "function b() {",
15
+ '\tconsole.log("x");',
16
+ "}",
17
+ ].join("\n");
18
+
19
+ // Após um "auto-fix" que remove o PRIMEIRO console.log.
20
+ const AFTER_FIX = [
21
+ "function a() {",
22
+ "}",
23
+ "function b() {",
24
+ '\tconsole.log("x");',
25
+ "}",
26
+ ].join("\n");
27
+
28
+ describe("finding-anchor", () => {
29
+ test("normalizeLine colapsa espaços e apara as pontas", () => {
30
+ expect(normalizeLine(' \t console.log( "x" ); ')).toBe('console.log( "x" );');
31
+ expect(normalizeLine(" ")).toBe("");
32
+ });
33
+
34
+ test("buildAnchor usa a linha quando válida", () => {
35
+ const anchor = buildAnchor(DUPLICATED, 2);
36
+ expect(anchor).not.toBeNull();
37
+ expect(anchor?.snippet).toBe('console.log("x");');
38
+ expect(anchor?.contextBefore).toBe("function a() {");
39
+ expect(anchor?.contextAfter).toBe("}");
40
+ expect(anchor?.line).toBe(2);
41
+ });
42
+
43
+ test("buildAnchor cai na evidence quando a linha é inválida", () => {
44
+ const anchor = buildAnchor(DUPLICATED, 999, 'console.log("x");');
45
+ expect(anchor?.line).toBe(2);
46
+ });
47
+
48
+ test("buildAnchor devolve null sem linha válida nem evidence localizável", () => {
49
+ expect(buildAnchor(DUPLICATED, 999, "inexistente")).toBeNull();
50
+ });
51
+
52
+ test("locateAnchor encontra a linha e conta as ocorrências", () => {
53
+ const anchor = buildAnchor(DUPLICATED, 2);
54
+ const located = locateAnchor(DUPLICATED, anchor);
55
+ expect(located.line).toBe(2);
56
+ expect(located.occurrences).toBe(1);
57
+ });
58
+
59
+ test("fingerprint é estável e distingue contexto", () => {
60
+ const a1 = buildAnchor(DUPLICATED, 2);
61
+ const a2 = buildAnchor(DUPLICATED, 5);
62
+
63
+ // Mesmo arquivo + regra + âncora → mesmo fingerprint.
64
+ expect(fingerprintOf("f.ts", "rule", a1)).toBe(
65
+ fingerprintOf("f.ts", "rule", buildAnchor(DUPLICATED, 2)),
66
+ );
67
+ // Snippet idêntico mas contexto diferente → fingerprints diferentes.
68
+ expect(fingerprintOf("f.ts", "rule", a1)).not.toBe(
69
+ fingerprintOf("f.ts", "rule", a2),
70
+ );
71
+ // Regra diferente muda o fingerprint.
72
+ expect(fingerprintOf("f.ts", "outra", a1)).not.toBe(
73
+ fingerprintOf("f.ts", "rule", a1),
74
+ );
75
+ });
76
+
77
+ test("fingerprint sem âncora usa o fallback", () => {
78
+ expect(fingerprintOf("f.ts", "rule", null, "mensagem")).toBe(
79
+ fingerprintOf("f.ts", "rule", null, "mensagem"),
80
+ );
81
+ expect(fingerprintOf("f.ts", "rule", null, "a")).not.toBe(
82
+ fingerprintOf("f.ts", "rule", null, "b"),
83
+ );
84
+ });
85
+
86
+ test("reanchor: ok, moved e stale", () => {
87
+ const anchor = buildAnchor(DUPLICATED, 5);
88
+ expect(reanchor(DUPLICATED, anchor, 5).status).toBe("ok");
89
+
90
+ const moved = reanchor(AFTER_FIX, anchor, 5);
91
+ expect(moved.status).toBe("moved");
92
+ expect(moved.line).toBe(4);
93
+
94
+ const removed = buildAnchor(DUPLICATED, 2);
95
+ expect(reanchor(AFTER_FIX, removed, 2).status).toBe("stale");
96
+ });
97
+
98
+ test("duas ocorrências iguais não se perdem quando o fix remove a primeira", () => {
99
+ const first = buildAnchor(DUPLICATED, 2);
100
+ const second = buildAnchor(DUPLICATED, 5);
101
+
102
+ // A âncora removida vira stale; a sobrevivente apenas desloca de linha.
103
+ expect(reanchor(AFTER_FIX, first, 2).status).toBe("stale");
104
+ const survivor = reanchor(AFTER_FIX, second, 5);
105
+ expect(survivor.status).toBe("moved");
106
+ expect(survivor.line).toBe(4);
107
+ });
108
+
109
+ test("ocorrências byte-idênticas compartilham o fingerprint (grupo)", () => {
110
+ // Mesmo snippet E mesmo contexto (sem vizinhos) → mesmo fingerprint.
111
+ const content = "x\ny\nx\ny";
112
+ const a1 = buildAnchor(content, 1);
113
+ const a2 = buildAnchor(content, 3);
114
+ // contextBefore ausente em ambos (linha 1 vs 3 tem 'y' antes → difere), então
115
+ // forçamos âncoras sem contexto para simular colisão real.
116
+ const bare = a1 && { ...a1, contextBefore: undefined, contextAfter: undefined };
117
+ const bare2 = a2 && { ...a2, contextBefore: undefined, contextAfter: undefined };
118
+ expect(fingerprintOf("f.ts", "r", bare)).toBe(fingerprintOf("f.ts", "r", bare2));
119
+ });
120
+ });
@@ -0,0 +1,226 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runFindingsCommand } from "../core/commands/findings";
6
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
7
+
8
+ /** Run fake em disco: classification.json + findings/alpha.json. */
9
+ function makeRun(findings: unknown[]): { dir: string; cleanup: () => void } {
10
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-findings-"));
11
+ const outDir = join(dir, "main");
12
+ mkdirSync(join(outDir, "findings"), { recursive: true });
13
+ writeFileSync(
14
+ join(outDir, "classification.json"),
15
+ JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
16
+ );
17
+ writeFileSync(
18
+ join(outDir, "findings", "alpha.json"),
19
+ JSON.stringify({
20
+ watchdogId: "alpha",
21
+ reviewedFiles: ["src/alpha/f.ts"],
22
+ skipped: [],
23
+ findings,
24
+ }),
25
+ );
26
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
27
+ }
28
+
29
+ const ONE_FINDING = [
30
+ {
31
+ rule: "Nunca usar any",
32
+ severity: "blocking",
33
+ file: "src/alpha/f.ts",
34
+ line: 2,
35
+ message: "any detectado",
36
+ action: "tipar",
37
+ },
38
+ ];
39
+
40
+ function run(args: string[]) {
41
+ return runFindingsCommand(fakeHoundsmanConfig, args);
42
+ }
43
+
44
+ describe("findings command", () => {
45
+ test("list reconstrói o estado a partir dos artefatos", () => {
46
+ const run0 = makeRun(ONE_FINDING);
47
+ try {
48
+ const result = run(["list", "--dir", run0.dir, "--branch", "main", "--json"]);
49
+ expect(result.exitCode).toBe(0);
50
+ const parsed = JSON.parse(result.output);
51
+ expect(parsed).toHaveLength(1);
52
+ expect(parsed[0].decision).toBe("pending");
53
+ expect(parsed[0].id).toBeTruthy();
54
+ expect(parsed[0].severity).toBe("blocking");
55
+ } finally {
56
+ run0.cleanup();
57
+ }
58
+ });
59
+
60
+ test("set atualiza decisão + comentário e persiste", () => {
61
+ const run0 = makeRun(ONE_FINDING);
62
+ try {
63
+ const list = JSON.parse(
64
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
65
+ );
66
+ const id = list[0].id;
67
+
68
+ const set = run([
69
+ "set",
70
+ id,
71
+ "--decision",
72
+ "execute",
73
+ "--comment",
74
+ "trocar por unknown",
75
+ "--dir",
76
+ run0.dir,
77
+ "--branch",
78
+ "main",
79
+ ]);
80
+ expect(set.exitCode).toBe(0);
81
+
82
+ const after = JSON.parse(
83
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
84
+ );
85
+ expect(after[0].decision).toBe("execute");
86
+ expect(after[0].comment).toBe("trocar por unknown");
87
+ expect(after[0].history.length).toBeGreaterThan(0);
88
+ } finally {
89
+ run0.cleanup();
90
+ }
91
+ });
92
+
93
+ test("done marca execState como done", () => {
94
+ const run0 = makeRun(ONE_FINDING);
95
+ try {
96
+ const id = JSON.parse(
97
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
98
+ )[0].id;
99
+ expect(
100
+ run(["done", id, "--dir", run0.dir, "--branch", "main"]).exitCode,
101
+ ).toBe(0);
102
+ const after = JSON.parse(
103
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
104
+ );
105
+ expect(after[0].execState).toBe("done");
106
+ } finally {
107
+ run0.cleanup();
108
+ }
109
+ });
110
+
111
+ test("filtra por decisão e resume contagens", () => {
112
+ const run0 = makeRun(ONE_FINDING);
113
+ try {
114
+ const id = JSON.parse(
115
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
116
+ )[0].id;
117
+ run([
118
+ "set",
119
+ id,
120
+ "--decision",
121
+ "ignore",
122
+ "--dir",
123
+ run0.dir,
124
+ "--branch",
125
+ "main",
126
+ ]);
127
+
128
+ const pending = JSON.parse(
129
+ run([
130
+ "list",
131
+ "--decision",
132
+ "pending",
133
+ "--dir",
134
+ run0.dir,
135
+ "--branch",
136
+ "main",
137
+ "--json",
138
+ ]).output,
139
+ );
140
+ expect(pending).toHaveLength(0);
141
+
142
+ const summary = JSON.parse(
143
+ run(["summary", "--dir", run0.dir, "--branch", "main", "--json"]).output,
144
+ );
145
+ expect(summary.total).toBe(1);
146
+ expect(summary.byDecision.ignore).toBe(1);
147
+ } finally {
148
+ run0.cleanup();
149
+ }
150
+ });
151
+
152
+ test("sync marca como stale quando o arquivo não está disponível", () => {
153
+ const run0 = makeRun(ONE_FINDING);
154
+ try {
155
+ const result = JSON.parse(
156
+ run(["sync", "--dir", run0.dir, "--branch", "main", "--json"]).output,
157
+ );
158
+ expect(result.stale).toHaveLength(1);
159
+ } finally {
160
+ run0.cleanup();
161
+ }
162
+ });
163
+
164
+ test("erros claros: decisão inválida, id inexistente, run ausente", () => {
165
+ const run0 = makeRun(ONE_FINDING);
166
+ try {
167
+ const bad = run([
168
+ "set",
169
+ "inexistente",
170
+ "--decision",
171
+ "xpto",
172
+ "--dir",
173
+ run0.dir,
174
+ "--branch",
175
+ "main",
176
+ ]);
177
+ expect(bad.exitCode).toBe(1);
178
+ expect(bad.output).toContain("--decision inválido");
179
+
180
+ const missing = run([
181
+ "show",
182
+ "nao-existe",
183
+ "--dir",
184
+ run0.dir,
185
+ "--branch",
186
+ "main",
187
+ ]);
188
+ expect(missing.exitCode).toBe(1);
189
+ expect(missing.output).toContain("não encontrado");
190
+
191
+ const empty = mkdtempSync(join(tmpdir(), "houndsman-empty-"));
192
+ try {
193
+ const absent = run(["list", "--dir", empty, "--branch", "main"]);
194
+ expect(absent.exitCode).toBe(1);
195
+ expect(absent.output).toContain("--finalize");
196
+ } finally {
197
+ rmSync(empty, { recursive: true, force: true });
198
+ }
199
+ } finally {
200
+ run0.cleanup();
201
+ }
202
+ });
203
+
204
+ test("path devolve o caminho do state.json", () => {
205
+ const run0 = makeRun(ONE_FINDING);
206
+ try {
207
+ run(["list", "--dir", run0.dir, "--branch", "main"]);
208
+ const result = run(["path", "--dir", run0.dir, "--branch", "main"]);
209
+ expect(result.exitCode).toBe(0);
210
+ expect(result.output.replace(/\\/g, "/")).toContain("main/state.json");
211
+ } finally {
212
+ run0.cleanup();
213
+ }
214
+ });
215
+
216
+ test("sub desconhecido devolve erro", () => {
217
+ const run0 = makeRun(ONE_FINDING);
218
+ try {
219
+ const result = run(["xpto", "--dir", run0.dir, "--branch", "main"]);
220
+ expect(result.exitCode).toBe(1);
221
+ expect(result.output).toContain("desconhecido");
222
+ } finally {
223
+ run0.cleanup();
224
+ }
225
+ });
226
+ });