@payconductor/houndsman 0.4.0 → 0.6.0
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.
- package/README.md +35 -41
- package/assets/grammars/tree-sitter-javascript.wasm +0 -0
- package/assets/grammars/tree-sitter-tsx.wasm +0 -0
- package/assets/grammars/tree-sitter-typescript.wasm +0 -0
- package/assets/grammars/web-tree-sitter.wasm +0 -0
- package/package.json +11 -3
- package/src/cli/commands/command.ts +5 -0
- package/src/cli/commands/doctor-command.ts +116 -24
- package/src/cli/commands/hunt-command.ts +25 -0
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +18 -1
- package/src/cli/index.ts +13 -6
- package/src/core/ai/ai-client.ts +221 -0
- package/src/core/ai/ai-sdk-model-provider.ts +101 -0
- package/src/core/ai/errors.ts +36 -0
- package/src/core/ai/features/brief-enricher.ts +62 -0
- package/src/core/ai/features/classifier.ts +50 -0
- package/src/core/ai/features/context-summarizer.ts +31 -0
- package/src/core/ai/features/pre-analyzer.ts +51 -0
- package/src/core/ai/features/verifier.ts +45 -0
- package/src/core/ai/index.ts +56 -0
- package/src/core/ai/model-factory.ts +137 -0
- package/src/core/ai/prompts/base.ts +36 -0
- package/src/core/ai/prompts/brief.ts +87 -0
- package/src/core/ai/prompts/classification.ts +36 -0
- package/src/core/ai/prompts/context.ts +29 -0
- package/src/core/ai/prompts/pre-analysis.ts +49 -0
- package/src/core/ai/prompts/verify.ts +40 -0
- package/src/core/ai/schemas.ts +110 -0
- package/src/core/ai/types.ts +76 -0
- package/src/core/ai/usage-budget.ts +75 -0
- package/src/core/ai/usage-recorder.ts +79 -0
- package/src/core/assets.ts +16 -0
- package/src/core/ast/diff.ts +220 -0
- package/src/core/ast/impact.ts +127 -0
- package/src/core/ast/parser.ts +167 -0
- package/src/core/ast/service.ts +233 -0
- package/src/core/ast/symbols.ts +294 -0
- package/src/core/ast/types.ts +125 -0
- package/src/core/brief.ts +138 -0
- package/src/core/commands/findings.ts +120 -6
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +159 -10
- package/src/core/commands/status.ts +63 -0
- package/src/core/commands/update.ts +5 -3
- package/src/core/config.ts +85 -0
- package/src/core/defaults.ts +27 -0
- package/src/core/domain/watchdog.ts +43 -5
- package/src/core/engine/houndsman-engine.ts +16 -1
- package/src/core/engine/review-scope.ts +77 -13
- package/src/core/engine/review-session.ts +27 -2
- package/src/core/env.ts +101 -0
- package/src/core/file-classifier.ts +25 -4
- package/src/core/findings-state.ts +21 -4
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-client.ts +8 -7
- package/src/core/jev/jev-verify.ts +2 -1
- package/src/core/logging/index.ts +17 -0
- package/src/core/logging/logger.ts +187 -0
- package/src/core/orchestrator.ts +454 -16
- package/src/core/paths.ts +3 -0
- package/src/core/project-config.ts +123 -1
- package/src/core/registry-check.ts +148 -6
- package/src/core/renderers/manifest-writer.ts +33 -0
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/rule-text.ts +97 -0
- package/src/core/run-status.ts +39 -5
- package/src/core/services/file-classifier.ts +10 -3
- package/src/core/services/verifier.ts +14 -3
- package/src/core/storage/file/file-storage.ts +2 -0
- package/src/core/storage/paths.ts +6 -5
- package/src/core/storage/sqlite/mappers.ts +2 -0
- package/src/core/storage/sqlite/schema.ts +9 -1
- package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
- package/src/core/storage/types.ts +15 -0
- package/src/core/storage/work-area.ts +12 -0
- package/src/core/types.ts +37 -1
- package/src/core/verify.ts +17 -5
- package/src/harness/README.md +15 -0
- package/src/harness/agent/agent-context.ts +33 -0
- package/src/harness/agent/agent-errors.ts +30 -0
- package/src/harness/agent/agent-factory.ts +45 -0
- package/src/harness/agent/agent-reporter.ts +34 -0
- package/src/harness/agent/agent-result.ts +16 -0
- package/src/harness/agent/agent-runner.ts +32 -0
- package/src/harness/agent/agent-scheduler.ts +37 -0
- package/src/harness/agent/review-agent.ts +391 -0
- package/src/harness/config/harness-config.ts +84 -0
- package/src/harness/hunt/hunt-harness.ts +83 -0
- package/src/harness/hunt/hunt-options.ts +75 -0
- package/src/harness/hunt/hunt-result.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +372 -0
- package/src/harness/hunt/review-planner.ts +98 -0
- package/src/harness/index.ts +36 -0
- package/src/harness/investigation/agent-state.ts +134 -0
- package/src/harness/investigation/evidence-verifier.ts +129 -0
- package/src/harness/investigation/evidence.ts +110 -0
- package/src/harness/investigation/finding-critic.ts +96 -0
- package/src/harness/investigation/investigation-planner.ts +118 -0
- package/src/harness/investigation/investigation-task.ts +10 -0
- package/src/harness/investigation/review-journal.ts +87 -0
- package/src/harness/investigation/rule-analyzer.ts +178 -0
- package/src/harness/investigation/rule-signals.ts +110 -0
- package/src/harness/prompts/agent-schemas.ts +57 -0
- package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
- package/src/harness/prompts/states/conclude-prompt.ts +16 -0
- package/src/harness/prompts/states/investigate-prompt.ts +37 -0
- package/src/harness/prompts/states/orient-prompt.ts +15 -0
- package/src/harness/prompts/states/verify-prompt.ts +22 -0
- package/src/harness/tools/agent-tool.ts +102 -0
- package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
- package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
- package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
- package/src/harness/tools/builtin/find-references-tool.ts +66 -0
- package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
- package/src/harness/tools/builtin/helpers.ts +115 -0
- package/src/harness/tools/builtin/impact-tool.ts +48 -0
- package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
- package/src/harness/tools/builtin/index.ts +59 -0
- package/src/harness/tools/builtin/list-files-tool.ts +85 -0
- package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
- package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
- package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
- package/src/harness/tools/builtin/search-code-tool.ts +65 -0
- package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
- package/src/harness/tools/path-guard.ts +90 -0
- package/src/harness/tools/result-limiter.ts +49 -0
- package/src/harness/tools/secret-redactor.ts +54 -0
- package/src/harness/tools/tool-cache.ts +50 -0
- package/src/harness/tools/tool-call-budget.ts +60 -0
- package/src/harness/tools/tool-context.ts +5 -0
- package/src/harness/tools/tool-middleware.ts +105 -0
- package/src/harness/tools/tool-policy.ts +51 -0
- package/src/harness/tools/tool-registry.ts +112 -0
- package/src/harness/tools/tool-result.ts +15 -0
- package/src/harness/tools/toolbox.ts +36 -0
- package/src/harness/validation/finding-validator.ts +119 -0
- package/src/index.ts +46 -0
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +12 -5
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +14 -10
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +92 -12
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +49 -6
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/{constants.ts → utils/constants.ts} +61 -10
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +64 -0
- package/src/server/index.ts +66 -7
- package/src/server/run-repository.ts +9 -0
- package/src/skill/OUTPUT-FORMAT.md +12 -0
- package/src/skill/SETUP.md +6 -2
- package/src/skill/example-review.md +22 -14
- package/src/skill/hunt.md +45 -19
- package/src/skill/verify.md +3 -2
- package/src/tests/__snapshots__/baseline.test.ts.snap +126 -42
- package/src/tests/ai-core.test.ts +120 -0
- package/src/tests/ai-features.test.ts +183 -0
- package/src/tests/ast.test.ts +335 -0
- package/src/tests/baseline.test.ts +3 -0
- package/src/tests/code-highlight.test.ts +1 -1
- package/src/tests/env.test.ts +69 -0
- package/src/tests/fallback.test.ts +172 -0
- package/src/tests/findings-state.test.ts +50 -2
- package/src/tests/fixtures/fake-agent-tools.ts +69 -0
- package/src/tests/fixtures/fake-investigation.ts +64 -0
- package/src/tests/fixtures/fake-model-provider.ts +48 -0
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/harness-agent.test.ts +122 -0
- package/src/tests/harness-investigation.test.ts +206 -0
- package/src/tests/harness-tools.test.ts +148 -0
- package/src/tests/hunt-command.test.ts +212 -0
- package/src/tests/logger.test.ts +83 -0
- package/src/tests/markdown.test.ts +61 -1
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +73 -0
- package/src/tests/rule-analyzer.test.ts +61 -0
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +53 -0
- package/src/tests/server.test.ts +76 -21
- package/src/tests/storage.test.ts +63 -0
- package/src/tests/tool-contracts.test.ts +82 -0
- package/src/tests/verify.test.ts +1 -1
- package/src/server/client/markdown.ts +0 -153
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
- /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isFilePath,
|
|
8
|
+
parseInline,
|
|
9
|
+
parseMarkdown,
|
|
10
|
+
safeUrl,
|
|
11
|
+
sectionBlocks,
|
|
12
|
+
} from "../server/client/utils/markdown";
|
|
7
13
|
|
|
8
14
|
describe("parseMarkdown", () => {
|
|
9
15
|
test("separa parágrafos, listas, quote e bloco de código", () => {
|
|
@@ -42,6 +48,40 @@ describe("parseMarkdown", () => {
|
|
|
42
48
|
test("texto vazio não gera blocos", () => {
|
|
43
49
|
expect(parseMarkdown(" \n\n ")).toEqual([]);
|
|
44
50
|
});
|
|
51
|
+
|
|
52
|
+
test("reconhece títulos, tabela e régua horizontal", () => {
|
|
53
|
+
const blocks = parseMarkdown(
|
|
54
|
+
"# Relatório\n\n| Arquivo | Severidade |\n|---|:---:|\n| a.ts | blocking |\n\n---",
|
|
55
|
+
);
|
|
56
|
+
expect(blocks).toEqual([
|
|
57
|
+
{ type: "heading", level: 1, text: "Relatório" },
|
|
58
|
+
{
|
|
59
|
+
type: "table",
|
|
60
|
+
align: [undefined, "center"],
|
|
61
|
+
header: ["Arquivo", "Severidade"],
|
|
62
|
+
rows: [["a.ts", "blocking"]],
|
|
63
|
+
},
|
|
64
|
+
{ type: "hr" },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("colunas só de números e barra alinham à direita", () => {
|
|
69
|
+
const blocks = parseMarkdown(
|
|
70
|
+
"| Arquivo | Linhas | Razão |\n|---|---|---|\n| a.ts | 12 | 3/4 |\n| b.ts | 7 | 1/2 |",
|
|
71
|
+
);
|
|
72
|
+
expect(blocks[0]).toMatchObject({
|
|
73
|
+
type: "table",
|
|
74
|
+
align: [undefined, "right", "right"],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("extrai os blocos de uma seção específica", () => {
|
|
79
|
+
const blocks = sectionBlocks(
|
|
80
|
+
"# Título\n\n## Resumo de negócio\n\nTexto A.\n\n## Pontos técnicos\n\nTexto B.",
|
|
81
|
+
"resumo de negócio",
|
|
82
|
+
);
|
|
83
|
+
expect(blocks).toEqual([{ type: "paragraph", text: "Texto A." }]);
|
|
84
|
+
});
|
|
45
85
|
});
|
|
46
86
|
|
|
47
87
|
describe("parseInline", () => {
|
|
@@ -74,6 +114,26 @@ describe("parseInline", () => {
|
|
|
74
114
|
{ type: "em", text: "rápido" },
|
|
75
115
|
]);
|
|
76
116
|
});
|
|
117
|
+
|
|
118
|
+
test("destaca caminhos de arquivo no texto corrido", () => {
|
|
119
|
+
expect(parseInline("veja src/foo/bar.ts e review.md")).toEqual([
|
|
120
|
+
{ type: "text", text: "veja " },
|
|
121
|
+
{ type: "path", text: "src/foo/bar.ts" },
|
|
122
|
+
{ type: "text", text: " e " },
|
|
123
|
+
{ type: "path", text: "review.md" },
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("isFilePath", () => {
|
|
129
|
+
test("reconhece arquivos e caminhos, sem confundir texto comum", () => {
|
|
130
|
+
expect(isFilePath("src/foo/bar.ts")).toBe(true);
|
|
131
|
+
expect(isFilePath("review.md")).toBe(true);
|
|
132
|
+
expect(isFilePath("./scripts/ci.sh")).toBe(true);
|
|
133
|
+
expect(isFilePath("M/D")).toBe(false);
|
|
134
|
+
expect(isFilePath("duas palavras.ts")).toBe(false);
|
|
135
|
+
expect(isFilePath("https://x.dev/file.ts")).toBe(false);
|
|
136
|
+
});
|
|
77
137
|
});
|
|
78
138
|
|
|
79
139
|
describe("safeUrl", () => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resumo final (`review-resume.md`) — conteúdo de negócio + pontos técnicos,
|
|
3
|
+
* determinístico e limitado a `RESUME_MAX_CHARS`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
RESUME_MAX_CHARS,
|
|
9
|
+
type ResumeClassification,
|
|
10
|
+
renderReviewResume,
|
|
11
|
+
} from "../core/renderers/resume-renderer";
|
|
12
|
+
import type { MergedFinding } from "../core/report-builder";
|
|
13
|
+
|
|
14
|
+
function finding(overrides: Partial<MergedFinding> = {}): MergedFinding {
|
|
15
|
+
return {
|
|
16
|
+
rule: "regra",
|
|
17
|
+
severity: "suggestion",
|
|
18
|
+
file: "src/a.ts",
|
|
19
|
+
line: 1,
|
|
20
|
+
message: "mensagem",
|
|
21
|
+
action: "ação",
|
|
22
|
+
watchdogs: ["alpha"],
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const classification: ResumeClassification = {
|
|
28
|
+
files: {
|
|
29
|
+
"src/a.ts": { status: "modified" },
|
|
30
|
+
"src/b.ts": { status: "added" },
|
|
31
|
+
},
|
|
32
|
+
watchdogs: { alpha: ["src/a.ts"], beta: ["src/b.ts"] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("renderReviewResume", () => {
|
|
36
|
+
test("une contexto de negócio e pontos técnicos", () => {
|
|
37
|
+
const md = renderReviewResume({
|
|
38
|
+
branch: "feat/x",
|
|
39
|
+
date: "2026-10-17",
|
|
40
|
+
context: {
|
|
41
|
+
available: true,
|
|
42
|
+
objective: "Entregar a feature X.",
|
|
43
|
+
motivation: "Reduzir o esforço manual.",
|
|
44
|
+
changeSummary: ["Novo fluxo de cadastro.", "Ajuste no relatório."],
|
|
45
|
+
},
|
|
46
|
+
classification,
|
|
47
|
+
findings: [
|
|
48
|
+
finding({ severity: "blocking", message: "quebra o cadastro" }),
|
|
49
|
+
finding({ severity: "attention", file: "src/b.ts", line: undefined }),
|
|
50
|
+
],
|
|
51
|
+
nameOf: (id) => id.toUpperCase(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(md).toContain("# Resumo da revisão — feat/x");
|
|
55
|
+
expect(md).toContain("**Data:** 2026-10-17");
|
|
56
|
+
expect(md).toContain("❌ Mudanças necessárias");
|
|
57
|
+
expect(md).toContain("Entregar a feature X.");
|
|
58
|
+
expect(md).toContain("**Por quê:** Reduzir o esforço manual.");
|
|
59
|
+
expect(md).toContain("- Novo fluxo de cadastro.");
|
|
60
|
+
expect(md).not.toContain("## Números");
|
|
61
|
+
expect(md).toContain("`src/a.ts:1` — quebra o cadastro _(ALPHA)_");
|
|
62
|
+
expect(md).toContain("`src/b.ts` — mensagem _(ALPHA)_");
|
|
63
|
+
expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("sem contexto da PR, cai no alcance da branch", () => {
|
|
67
|
+
const md = renderReviewResume({
|
|
68
|
+
branch: "main",
|
|
69
|
+
classification,
|
|
70
|
+
findings: [],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(md).toContain("Sem resumo de contexto da PR disponível.");
|
|
74
|
+
expect(md).toContain("✅ Aprovado");
|
|
75
|
+
expect(md).toContain("- Nenhum bloqueio ou ponto de atenção.");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("respeita o teto de caracteres", () => {
|
|
79
|
+
const findings = Array.from({ length: 40 }, (_, index) =>
|
|
80
|
+
finding({
|
|
81
|
+
severity: index % 2 === 0 ? "blocking" : "attention",
|
|
82
|
+
file: `src/very/long/path/file-${index}.ts`,
|
|
83
|
+
message: "x".repeat(300),
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
const md = renderReviewResume({
|
|
87
|
+
branch: "main",
|
|
88
|
+
context: {
|
|
89
|
+
available: true,
|
|
90
|
+
objective: "o".repeat(1000),
|
|
91
|
+
motivation: "m".repeat(1000),
|
|
92
|
+
changeSummary: ["a".repeat(200), "b".repeat(200)],
|
|
93
|
+
},
|
|
94
|
+
classification,
|
|
95
|
+
findings,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
|
|
99
|
+
expect(md.endsWith("…")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escopo do review: completo × incremental × no-op.
|
|
3
|
+
*
|
|
4
|
+
* Congela a decisão que evita re-revisar a PR inteira: mesmo commit revisado
|
|
5
|
+
* vira no-op (sem `--full`/`--clean`), delta vira incremental com carry-over.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { ReviewScopeResolver, type ScopeOptions } from "../core/engine/review-scope";
|
|
13
|
+
import type { FindingState, FindingsStateFile } from "../core/findings-state";
|
|
14
|
+
import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
|
|
15
|
+
import { resolveRepoIdentity } from "../core/storage/repo-identity";
|
|
16
|
+
import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
|
|
17
|
+
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function findingState(overrides: Partial<FindingState> = {}): FindingState {
|
|
24
|
+
return {
|
|
25
|
+
id: "abcdef1234",
|
|
26
|
+
fingerprint: "0123456789abcdef",
|
|
27
|
+
watchdogIds: ["alpha"],
|
|
28
|
+
rule: "regra",
|
|
29
|
+
severity: "blocking",
|
|
30
|
+
file: "src/alpha/keep.ts",
|
|
31
|
+
line: 1,
|
|
32
|
+
message: "mensagem",
|
|
33
|
+
action: "ação",
|
|
34
|
+
anchor: null,
|
|
35
|
+
count: 1,
|
|
36
|
+
present: true,
|
|
37
|
+
decision: "pending",
|
|
38
|
+
execState: "pending",
|
|
39
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
40
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
41
|
+
history: [],
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stateWith(
|
|
47
|
+
reviewedSha: string,
|
|
48
|
+
findings: Record<string, FindingState> = {},
|
|
49
|
+
): FindingsStateFile {
|
|
50
|
+
return {
|
|
51
|
+
version: 2,
|
|
52
|
+
branch: "main",
|
|
53
|
+
slug: "main",
|
|
54
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
55
|
+
order: Object.keys(findings),
|
|
56
|
+
reviewed: { headSha: reviewedSha, at: "2026-01-01T00:00:00.000Z" },
|
|
57
|
+
findings,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function options(overrides: Partial<ScopeOptions> = {}): ScopeOptions {
|
|
62
|
+
return { base: "main", head: "HEAD", full: false, clean: false, ...overrides };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function git(cwd: string, args: string[]): string {
|
|
66
|
+
const result = Bun.spawnSync(["git", ...args], {
|
|
67
|
+
cwd,
|
|
68
|
+
stdout: "pipe",
|
|
69
|
+
stderr: "pipe",
|
|
70
|
+
});
|
|
71
|
+
if (result.exitCode !== 0) {
|
|
72
|
+
throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`);
|
|
73
|
+
}
|
|
74
|
+
return result.stdout.toString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Repo = { cwd: string; base: string; head: string; cleanup: () => void };
|
|
78
|
+
|
|
79
|
+
/** Repositório temporário: `base` → `head` altera `src/alpha/a.ts`. */
|
|
80
|
+
function makeRepo(): Repo {
|
|
81
|
+
const cwd = mkdtempSync(join(tmpdir(), "houndsman-scope-"));
|
|
82
|
+
git(cwd, ["init", "-b", "main"]);
|
|
83
|
+
git(cwd, ["config", "user.email", "scope@example.test"]);
|
|
84
|
+
git(cwd, ["config", "user.name", "Scope"]);
|
|
85
|
+
git(cwd, ["config", "commit.gpgsign", "false"]);
|
|
86
|
+
git(cwd, ["config", "tag.gpgsign", "false"]);
|
|
87
|
+
|
|
88
|
+
mkdirSync(join(cwd, "src", "alpha"), { recursive: true });
|
|
89
|
+
writeFileSync(join(cwd, "src", "alpha", "a.ts"), "export const a = 1;\n");
|
|
90
|
+
writeFileSync(join(cwd, "src", "alpha", "keep.ts"), "export const keep = 1;\n");
|
|
91
|
+
writeFileSync(join(cwd, "README.md"), "# Fake\n");
|
|
92
|
+
git(cwd, ["add", "."]);
|
|
93
|
+
git(cwd, ["commit", "-m", "initial"]);
|
|
94
|
+
const base = git(cwd, ["rev-parse", "HEAD"]).trim();
|
|
95
|
+
|
|
96
|
+
writeFileSync(join(cwd, "src", "alpha", "a.ts"), "export const a = 2;\n");
|
|
97
|
+
git(cwd, ["add", "-A"]);
|
|
98
|
+
git(cwd, ["commit", "-m", "change"]);
|
|
99
|
+
const head = git(cwd, ["rev-parse", "HEAD"]).trim();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
cwd,
|
|
103
|
+
base,
|
|
104
|
+
head,
|
|
105
|
+
cleanup: () => rmSync(cwd, { recursive: true, force: true }),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Decisão de escopo
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
describe("ReviewScopeResolver", () => {
|
|
114
|
+
test("mesmo commit revisado vira no-op (sem tocar no git)", async () => {
|
|
115
|
+
const sha = "a".repeat(40);
|
|
116
|
+
const scope = await new ReviewScopeResolver(
|
|
117
|
+
options(),
|
|
118
|
+
fakeHoundsmanConfig,
|
|
119
|
+
).resolve(stateWith(sha), sha);
|
|
120
|
+
|
|
121
|
+
expect(scope.kind).toBe("noop");
|
|
122
|
+
if (scope.kind !== "noop") throw new Error("esperava noop");
|
|
123
|
+
expect(scope.reviewedFrom).toBe(sha);
|
|
124
|
+
expect(scope.headSha).toBe(sha);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("`--full` re-revisa o mesmo commit", async () => {
|
|
128
|
+
const repo = makeRepo();
|
|
129
|
+
const originalCwd = process.cwd();
|
|
130
|
+
try {
|
|
131
|
+
process.chdir(repo.cwd);
|
|
132
|
+
const scope = await new ReviewScopeResolver(
|
|
133
|
+
options({ base: repo.base, full: true }),
|
|
134
|
+
fakeHoundsmanConfig,
|
|
135
|
+
).resolve(stateWith(repo.head), repo.head);
|
|
136
|
+
|
|
137
|
+
expect(scope.kind).toBe("review");
|
|
138
|
+
if (scope.kind !== "review") throw new Error("esperava review");
|
|
139
|
+
expect(scope.classification.mode).toBe("full");
|
|
140
|
+
expect(scope.classification.headSha).toBe(repo.head);
|
|
141
|
+
} finally {
|
|
142
|
+
process.chdir(originalCwd);
|
|
143
|
+
repo.cleanup();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("delta revisa só os arquivos alterados e carrega o resto", async () => {
|
|
148
|
+
const repo = makeRepo();
|
|
149
|
+
const originalCwd = process.cwd();
|
|
150
|
+
try {
|
|
151
|
+
process.chdir(repo.cwd);
|
|
152
|
+
const previous = stateWith(repo.base, {
|
|
153
|
+
abcdef1234: findingState({ file: "src/alpha/keep.ts" }),
|
|
154
|
+
fedcba4321: findingState({ file: "src/alpha/a.ts" }),
|
|
155
|
+
});
|
|
156
|
+
const scope = await new ReviewScopeResolver(
|
|
157
|
+
options({ base: repo.base }),
|
|
158
|
+
fakeHoundsmanConfig,
|
|
159
|
+
).resolve(previous, repo.head);
|
|
160
|
+
|
|
161
|
+
expect(scope.kind).toBe("review");
|
|
162
|
+
if (scope.kind !== "review") throw new Error("esperava review");
|
|
163
|
+
expect(scope.classification.mode).toBe("incremental");
|
|
164
|
+
expect(scope.classification.reviewedFrom).toBe(repo.base);
|
|
165
|
+
expect(Object.keys(scope.classification.files)).toEqual(["src/alpha/a.ts"]);
|
|
166
|
+
expect(scope.classification.carryover?.map((f) => f.file)).toEqual([
|
|
167
|
+
"src/alpha/keep.ts",
|
|
168
|
+
]);
|
|
169
|
+
} finally {
|
|
170
|
+
process.chdir(originalCwd);
|
|
171
|
+
repo.cleanup();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("histórico reescrito (HEAD sem ancestrais comuns) cai no completo", async () => {
|
|
176
|
+
const repo = makeRepo();
|
|
177
|
+
const originalCwd = process.cwd();
|
|
178
|
+
try {
|
|
179
|
+
process.chdir(repo.cwd);
|
|
180
|
+
// `repo.head` não é ancestral de `repo.base` → revisão completa.
|
|
181
|
+
const scope = await new ReviewScopeResolver(
|
|
182
|
+
options({ base: repo.base }),
|
|
183
|
+
fakeHoundsmanConfig,
|
|
184
|
+
).resolve(stateWith(repo.head), repo.base);
|
|
185
|
+
|
|
186
|
+
expect(scope.kind).toBe("review");
|
|
187
|
+
if (scope.kind !== "review") throw new Error("esperava review");
|
|
188
|
+
expect(scope.classification.mode).toBe("full");
|
|
189
|
+
} finally {
|
|
190
|
+
process.chdir(originalCwd);
|
|
191
|
+
repo.cleanup();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Integração: prepare/finalize no mesmo commit
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
describe("prepare/finalize — mesmo commit", () => {
|
|
201
|
+
test("no-op preserva relatório e não gera briefs", async () => {
|
|
202
|
+
const repo = makeRepo();
|
|
203
|
+
const originalCwd = process.cwd();
|
|
204
|
+
const originalHome = process.env.HOUNDSMAN_HOME;
|
|
205
|
+
let storage: SQLiteStorageProvider | null = null;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
process.env.HOUNDSMAN_HOME = join(repo.cwd, ".home");
|
|
209
|
+
process.chdir(repo.cwd);
|
|
210
|
+
|
|
211
|
+
storage = new SQLiteStorageProvider(join(repo.cwd, "houndsman.db"));
|
|
212
|
+
const record = storage.getOrCreateReview({
|
|
213
|
+
repo: resolveRepoIdentity(),
|
|
214
|
+
branch: "main",
|
|
215
|
+
slug: "main",
|
|
216
|
+
});
|
|
217
|
+
const opts = {
|
|
218
|
+
...defaultOptions(),
|
|
219
|
+
base: repo.base,
|
|
220
|
+
head: "HEAD",
|
|
221
|
+
noJev: true,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let ctx = reviewContext(storage, record);
|
|
225
|
+
await prepare(opts, "main", fakeHoundsmanConfig, ctx);
|
|
226
|
+
expect(storage.getReview(record.id)?.status).toBe("reviewing");
|
|
227
|
+
|
|
228
|
+
writeFileSync(
|
|
229
|
+
ctx.work.findingFile("alpha"),
|
|
230
|
+
`${JSON.stringify(
|
|
231
|
+
{
|
|
232
|
+
watchdogId: "alpha",
|
|
233
|
+
reviewedFiles: ["src/alpha/a.ts"],
|
|
234
|
+
skipped: [],
|
|
235
|
+
findings: [
|
|
236
|
+
{
|
|
237
|
+
rule: "regra",
|
|
238
|
+
severity: "blocking",
|
|
239
|
+
file: "src/alpha/a.ts",
|
|
240
|
+
line: 1,
|
|
241
|
+
message: "achado",
|
|
242
|
+
action: "corrigir",
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
null,
|
|
247
|
+
2,
|
|
248
|
+
)}\n`,
|
|
249
|
+
);
|
|
250
|
+
finalize(opts, "main", fakeHoundsmanConfig, ctx);
|
|
251
|
+
|
|
252
|
+
const classificationBefore = storage.readArtifact(
|
|
253
|
+
record.id,
|
|
254
|
+
"classification.json",
|
|
255
|
+
)?.content;
|
|
256
|
+
const reportBefore = storage.readArtifact(record.id, "review.md")?.content;
|
|
257
|
+
const findingsBefore = JSON.stringify(
|
|
258
|
+
storage.readFindings(record.id)?.findings,
|
|
259
|
+
);
|
|
260
|
+
const reviewedBefore = storage.getReview(record.id)?.reviewedSha;
|
|
261
|
+
expect(classificationBefore).toBeTruthy();
|
|
262
|
+
expect(reportBefore).toContain("src/alpha/a.ts");
|
|
263
|
+
|
|
264
|
+
// Mesmo commit: prepare vira no-op e nada é reescrito.
|
|
265
|
+
ctx = reviewContext(storage, storage.getReview(record.id) ?? record);
|
|
266
|
+
const again = await prepare(opts, "main", fakeHoundsmanConfig, ctx);
|
|
267
|
+
expect(again.messages.join("\n")).toContain(
|
|
268
|
+
"Nenhuma alteração desde a última revisão",
|
|
269
|
+
);
|
|
270
|
+
expect(storage.readArtifact(record.id, "classification.json")?.content).toBe(
|
|
271
|
+
classificationBefore,
|
|
272
|
+
);
|
|
273
|
+
expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
|
|
274
|
+
reportBefore,
|
|
275
|
+
);
|
|
276
|
+
expect(storage.getReview(record.id)?.status).toBe("review_done");
|
|
277
|
+
expect(existsSync(ctx.work.briefFile("alpha"))).toBe(false);
|
|
278
|
+
|
|
279
|
+
// Finalize sem ciclo preparado: no-op (não degrada o relatório).
|
|
280
|
+
const finalizedAgain = finalize(
|
|
281
|
+
opts,
|
|
282
|
+
"main",
|
|
283
|
+
fakeHoundsmanConfig,
|
|
284
|
+
reviewContext(storage, storage.getReview(record.id) ?? record),
|
|
285
|
+
);
|
|
286
|
+
expect(finalizedAgain.messages.join("\n")).toContain("Nada a finalizar");
|
|
287
|
+
expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
|
|
288
|
+
reportBefore,
|
|
289
|
+
);
|
|
290
|
+
expect(JSON.stringify(storage.readFindings(record.id)?.findings)).toBe(
|
|
291
|
+
findingsBefore,
|
|
292
|
+
);
|
|
293
|
+
expect(storage.getReview(record.id)?.reviewedSha).toBe(reviewedBefore);
|
|
294
|
+
} finally {
|
|
295
|
+
storage?.close();
|
|
296
|
+
process.chdir(originalCwd);
|
|
297
|
+
if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
|
|
298
|
+
else process.env.HOUNDSMAN_HOME = originalHome;
|
|
299
|
+
repo.cleanup();
|
|
300
|
+
}
|
|
301
|
+
}, 30_000);
|
|
302
|
+
});
|
|
@@ -673,6 +673,79 @@ describe("report-builder", () => {
|
|
|
673
673
|
expect(md).toContain("review.alpha.md");
|
|
674
674
|
});
|
|
675
675
|
|
|
676
|
+
test("renderReport coloca o sumário antes dos achados", () => {
|
|
677
|
+
const md = renderReport(
|
|
678
|
+
"main",
|
|
679
|
+
[{ watchdogId: "alpha", reviewedFiles: ["a.ts"], skipped: [], findings: [] }],
|
|
680
|
+
[
|
|
681
|
+
{
|
|
682
|
+
rule: "R1",
|
|
683
|
+
severity: "blocking" as const,
|
|
684
|
+
file: "a.ts",
|
|
685
|
+
line: 1,
|
|
686
|
+
message: "m",
|
|
687
|
+
action: "a",
|
|
688
|
+
watchdogs: ["alpha"],
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
{ classified: { alpha: ["a.ts"] } },
|
|
692
|
+
null,
|
|
693
|
+
undefined,
|
|
694
|
+
undefined,
|
|
695
|
+
[
|
|
696
|
+
{
|
|
697
|
+
id: "alpha",
|
|
698
|
+
name: "Alpha",
|
|
699
|
+
file: "review.alpha.md",
|
|
700
|
+
findings: 1,
|
|
701
|
+
reviewedFiles: 1,
|
|
702
|
+
expectedFiles: 1,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
);
|
|
706
|
+
const sections = [
|
|
707
|
+
"## Relatórios por domínio",
|
|
708
|
+
"## Cobertura",
|
|
709
|
+
"## Conclusão",
|
|
710
|
+
"## ❌ blocking",
|
|
711
|
+
].map((section) => md.indexOf(section));
|
|
712
|
+
expect(sections.every((index) => index >= 0)).toBe(true);
|
|
713
|
+
expect([...sections].sort((a, b) => a - b)).toEqual(sections);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("renderReport anexa candidatos Jev depois dos achados", () => {
|
|
717
|
+
const md = renderReport(
|
|
718
|
+
"main",
|
|
719
|
+
[{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
|
|
720
|
+
[
|
|
721
|
+
{
|
|
722
|
+
rule: "R1",
|
|
723
|
+
severity: "blocking" as const,
|
|
724
|
+
file: "a.ts",
|
|
725
|
+
line: 1,
|
|
726
|
+
message: "m",
|
|
727
|
+
action: "a",
|
|
728
|
+
watchdogs: ["alpha"],
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
{},
|
|
732
|
+
null,
|
|
733
|
+
[
|
|
734
|
+
{
|
|
735
|
+
watchdogId: "alpha",
|
|
736
|
+
rule: "R2",
|
|
737
|
+
severity: "attention",
|
|
738
|
+
file: "b.ts",
|
|
739
|
+
confidence: 0.6,
|
|
740
|
+
questionKey: "q",
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
);
|
|
744
|
+
expect(md.indexOf("## ❌ blocking")).toBeLessThan(
|
|
745
|
+
md.indexOf("## 🔎 Candidatos não verificados"),
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
|
|
676
749
|
test("renderDomainReport mostra cobertura e não revisados", () => {
|
|
677
750
|
const md = renderDomainReport(
|
|
678
751
|
"main",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuleAnalyzer — polaridade, categoria, sinais e hints.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { RuleAnalyzer } from "../harness/investigation/rule-analyzer";
|
|
7
|
+
|
|
8
|
+
const analyzer = new RuleAnalyzer();
|
|
9
|
+
|
|
10
|
+
describe("RuleAnalyzer", () => {
|
|
11
|
+
test("detecta polaridade explícita", () => {
|
|
12
|
+
const [avoid, promote] = analyzer.analyze([
|
|
13
|
+
{ severity: "blocking", text: "Evitar: chamar banco diretamente em rotas." },
|
|
14
|
+
{ severity: "attention", text: "Promover: usar `logger` estruturado." },
|
|
15
|
+
]);
|
|
16
|
+
expect(avoid?.intent).toBe("avoid");
|
|
17
|
+
expect(promote?.intent).toBe("promote");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("classifica categoria por keywords", () => {
|
|
21
|
+
const [data, tests] = analyzer.analyze([
|
|
22
|
+
{
|
|
23
|
+
severity: "attention",
|
|
24
|
+
text: "Evitar: query direta no banco sem migration.",
|
|
25
|
+
},
|
|
26
|
+
{ severity: "nit", text: "Promover: cobertura de teste no handler." },
|
|
27
|
+
]);
|
|
28
|
+
expect(data?.category).toBe("data");
|
|
29
|
+
expect(tests?.category).toBe("tests");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("extrai sinais de backticks e hints (confiança alta)", () => {
|
|
33
|
+
const [analysis] = analyzer.analyze([
|
|
34
|
+
{
|
|
35
|
+
severity: "blocking",
|
|
36
|
+
text: "Evitar: usar `db.query` fora do service.",
|
|
37
|
+
hints: {
|
|
38
|
+
symbols: ["OrderService"],
|
|
39
|
+
imports: ["db"],
|
|
40
|
+
checks: ["symbol_context"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
expect(analysis?.confidence).toBe("high");
|
|
45
|
+
expect(analysis?.signals.some((signal) => signal.value === "OrderService")).toBe(
|
|
46
|
+
true,
|
|
47
|
+
);
|
|
48
|
+
expect(analysis?.signals.some((signal) => signal.value === "db")).toBe(true);
|
|
49
|
+
expect(analysis?.checks.some((check) => check.tool === "symbol_context")).toBe(
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("keywords ignoram stopwords", () => {
|
|
55
|
+
const [analysis] = analyzer.analyze([
|
|
56
|
+
{ severity: "nit", text: "Nunca usar any em produção" },
|
|
57
|
+
]);
|
|
58
|
+
expect(analysis?.keywords).not.toContain("nunca");
|
|
59
|
+
expect(analysis?.keywords).toContain("produção");
|
|
60
|
+
});
|
|
61
|
+
});
|