@payconductor/houndsman 0.3.0 → 0.5.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 +25 -39
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +8 -3
- package/src/cli/index.ts +5 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +216 -125
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/commands/status.ts +63 -0
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +53 -75
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +271 -134
- package/src/core/paths.ts +26 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +59 -50
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +271 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +162 -0
- package/src/core/storage/sqlite/schema.ts +116 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
- package/src/core/storage/types.ts +152 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +144 -23
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +50 -15
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +181 -0
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +58 -10
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/utils/code-highlight.ts +422 -0
- package/src/server/client/utils/constants.ts +226 -0
- package/src/server/client/utils/json-highlight.ts +55 -0
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +81 -0
- package/src/server/index.ts +154 -16
- package/src/server/run-repository.ts +153 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/example-review.md +22 -14
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +55 -32
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +7 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
- package/src/tests/baseline.test.ts +79 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +56 -5
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +149 -0
- 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 +81 -5
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +86 -23
- package/src/tests/server.test.ts +184 -96
- package/src/tests/storage.test.ts +449 -0
- package/src/server/client/constants.ts +0 -91
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown mínimo do `action` — blocos, inline e sanitização de links.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
isFilePath,
|
|
8
|
+
parseInline,
|
|
9
|
+
parseMarkdown,
|
|
10
|
+
safeUrl,
|
|
11
|
+
sectionBlocks,
|
|
12
|
+
} from "../server/client/utils/markdown";
|
|
13
|
+
|
|
14
|
+
describe("parseMarkdown", () => {
|
|
15
|
+
test("separa parágrafos, listas, quote e bloco de código", () => {
|
|
16
|
+
const blocks = parseMarkdown(
|
|
17
|
+
"Primeira linha\nsegunda linha.\n\n- um\n- dois\n\n1. passo\n2. outro\n\n> nota\n\n```ts\nconst x = 1;\n```",
|
|
18
|
+
);
|
|
19
|
+
expect(blocks.map((block) => block.type)).toEqual([
|
|
20
|
+
"paragraph",
|
|
21
|
+
"list",
|
|
22
|
+
"list",
|
|
23
|
+
"quote",
|
|
24
|
+
"code",
|
|
25
|
+
]);
|
|
26
|
+
expect(blocks[0]).toEqual({
|
|
27
|
+
type: "paragraph",
|
|
28
|
+
text: "Primeira linha\nsegunda linha.",
|
|
29
|
+
});
|
|
30
|
+
expect(blocks[1]).toEqual({
|
|
31
|
+
type: "list",
|
|
32
|
+
ordered: false,
|
|
33
|
+
items: ["um", "dois"],
|
|
34
|
+
});
|
|
35
|
+
expect(blocks[2]).toEqual({
|
|
36
|
+
type: "list",
|
|
37
|
+
ordered: true,
|
|
38
|
+
items: ["passo", "outro"],
|
|
39
|
+
});
|
|
40
|
+
expect(blocks[3]).toEqual({ type: "quote", text: "nota" });
|
|
41
|
+
expect(blocks[4]).toEqual({
|
|
42
|
+
type: "code",
|
|
43
|
+
language: "ts",
|
|
44
|
+
code: "const x = 1;",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("texto vazio não gera blocos", () => {
|
|
49
|
+
expect(parseMarkdown(" \n\n ")).toEqual([]);
|
|
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
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("parseInline", () => {
|
|
88
|
+
test("reconhece code, bold, itálico e link na ordem", () => {
|
|
89
|
+
const tokens = parseInline(
|
|
90
|
+
"Use `npm run ci` e **sempre** valide _tudo_ em [docs](https://x.dev).",
|
|
91
|
+
);
|
|
92
|
+
expect(tokens).toEqual([
|
|
93
|
+
{ type: "text", text: "Use " },
|
|
94
|
+
{ type: "code", text: "npm run ci" },
|
|
95
|
+
{ type: "text", text: " e " },
|
|
96
|
+
{ type: "strong", text: "sempre" },
|
|
97
|
+
{ type: "text", text: " valide " },
|
|
98
|
+
{ type: "em", text: "tudo" },
|
|
99
|
+
{ type: "text", text: " em " },
|
|
100
|
+
{ type: "link", text: "docs", url: "https://x.dev" },
|
|
101
|
+
{ type: "text", text: "." },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("não confunde underscore de identificador com itálico", () => {
|
|
106
|
+
expect(parseInline("foo_bar_baz")).toEqual([
|
|
107
|
+
{ type: "text", text: "foo_bar_baz" },
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("asterisco simples também vira itálico", () => {
|
|
112
|
+
expect(parseInline("seja *rápido*")).toEqual([
|
|
113
|
+
{ type: "text", text: "seja " },
|
|
114
|
+
{ type: "em", text: "rápido" },
|
|
115
|
+
]);
|
|
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
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("safeUrl", () => {
|
|
140
|
+
test("aceita http(s)/mailto e caminho relativo; barra o resto", () => {
|
|
141
|
+
expect(safeUrl("https://x.dev")).toBe("https://x.dev");
|
|
142
|
+
expect(safeUrl("http://x.dev")).toBe("http://x.dev");
|
|
143
|
+
expect(safeUrl("mailto:a@b.c")).toBe("mailto:a@b.c");
|
|
144
|
+
expect(safeUrl("/docs")).toBe("/docs");
|
|
145
|
+
expect(safeUrl("javascript:alert(1)")).toBeNull();
|
|
146
|
+
expect(safeUrl("//evil.dev")).toBeNull();
|
|
147
|
+
expect(safeUrl("data:text/html,x")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -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)", () => {
|
|
115
|
+
const sha = "a".repeat(40);
|
|
116
|
+
const scope = new ReviewScopeResolver(options(), fakeHoundsmanConfig).resolve(
|
|
117
|
+
stateWith(sha),
|
|
118
|
+
sha,
|
|
119
|
+
);
|
|
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", () => {
|
|
128
|
+
const repo = makeRepo();
|
|
129
|
+
const originalCwd = process.cwd();
|
|
130
|
+
try {
|
|
131
|
+
process.chdir(repo.cwd);
|
|
132
|
+
const scope = 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", () => {
|
|
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 = 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", () => {
|
|
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 = 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",
|
|
@@ -1150,13 +1223,16 @@ describe("jev-candidates", () => {
|
|
|
1150
1223
|
|
|
1151
1224
|
describe("parseReviewArgs", () => {
|
|
1152
1225
|
test("lê flags de valor e booleanas", () => {
|
|
1153
|
-
const opts = parseReviewArgs(
|
|
1154
|
-
|
|
1155
|
-
"
|
|
1156
|
-
|
|
1226
|
+
const opts = parseReviewArgs([
|
|
1227
|
+
"--base",
|
|
1228
|
+
"develop",
|
|
1229
|
+
"--branch",
|
|
1230
|
+
"feature/x",
|
|
1231
|
+
"--finalize",
|
|
1232
|
+
"--clean",
|
|
1233
|
+
]);
|
|
1157
1234
|
expect(opts.base).toBe("develop");
|
|
1158
1235
|
expect(opts.branch).toBe("feature/x");
|
|
1159
|
-
expect(opts.dir).toBe("tmp/artifacts");
|
|
1160
1236
|
expect(opts.finalize).toBe(true);
|
|
1161
1237
|
expect(opts.clean).toBe(true);
|
|
1162
1238
|
expect(opts.failOnBlocking).toBe(false);
|