@payconductor/houndsman 0.4.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 +12 -38
- package/package.json +1 -1
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/core/commands/findings.ts +61 -5
- package/src/core/commands/status.ts +63 -0
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/findings-state.ts +21 -4
- package/src/core/github-review.ts +620 -57
- package/src/core/orchestrator.ts +98 -8
- package/src/core/paths.ts +3 -0
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-status.ts +39 -5
- package/src/core/storage/file/file-storage.ts +2 -0
- 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 +9 -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} +34 -5
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +58 -0
- package/src/server/index.ts +63 -5
- package/src/server/run-repository.ts +9 -0
- package/src/skill/example-review.md +22 -14
- package/src/skill/hunt.md +31 -17
- package/src/skill/verify.md +2 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +32 -30
- package/src/tests/baseline.test.ts +3 -0
- package/src/tests/code-highlight.test.ts +1 -1
- package/src/tests/findings-state.test.ts +50 -2
- package/src/tests/github-review.test.ts +409 -60
- 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/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/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
|
@@ -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",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View do status na sidebar — timeout só vale quando há heartbeat de execução.
|
|
3
|
+
* `updatedAt` (triagem/sync) não deve simular uma revisão em andamento.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
describeRunStatus,
|
|
9
|
+
REVIEW_TIMEOUT_MS,
|
|
10
|
+
STATUS_TIMEOUT_MS,
|
|
11
|
+
} from "../server/client/utils/constants";
|
|
12
|
+
import type { RunSummary, Summary } from "../server/client/utils/types";
|
|
13
|
+
|
|
14
|
+
const summary: Summary = {
|
|
15
|
+
total: 0,
|
|
16
|
+
present: 0,
|
|
17
|
+
byDecision: { pending: 0, execute: 0, ignore: 0 },
|
|
18
|
+
byExecState: { pending: 0, in_progress: 0, done: 0, stale: 0 },
|
|
19
|
+
bySeverity: { blocking: 0, attention: 0, suggestion: 0, nit: 0 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const NOW = Date.parse("2026-10-17T12:00:00.000Z");
|
|
23
|
+
const iso = (offsetMs: number): string => new Date(NOW + offsetMs).toISOString();
|
|
24
|
+
|
|
25
|
+
function run(overrides: Partial<RunSummary>): RunSummary {
|
|
26
|
+
return {
|
|
27
|
+
slug: "feature-x",
|
|
28
|
+
branch: "feature/x",
|
|
29
|
+
summary,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("describeRunStatus", () => {
|
|
35
|
+
test("heartbeat recente mantém o status de execução", () => {
|
|
36
|
+
expect(
|
|
37
|
+
describeRunStatus(
|
|
38
|
+
run({ status: "reviewing", heartbeatAt: iso(-60_000) }),
|
|
39
|
+
NOW,
|
|
40
|
+
),
|
|
41
|
+
).toEqual({ kind: "reviewing", label: "Revisando", timeout: false });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("heartbeat antigo vira timeout", () => {
|
|
45
|
+
expect(
|
|
46
|
+
describeRunStatus(
|
|
47
|
+
run({
|
|
48
|
+
status: "reviewing",
|
|
49
|
+
heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
|
|
50
|
+
}),
|
|
51
|
+
NOW,
|
|
52
|
+
),
|
|
53
|
+
).toEqual({ kind: "timeout", label: "Timeout na revisão", timeout: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("updatedAt recente não mascara heartbeat antigo", () => {
|
|
57
|
+
expect(
|
|
58
|
+
describeRunStatus(
|
|
59
|
+
run({
|
|
60
|
+
status: "reviewing",
|
|
61
|
+
updatedAt: iso(-1_000),
|
|
62
|
+
heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
|
|
63
|
+
}),
|
|
64
|
+
NOW,
|
|
65
|
+
)?.timeout,
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("sem heartbeat não inventa revisão em andamento", () => {
|
|
70
|
+
expect(
|
|
71
|
+
describeRunStatus(run({ status: "reviewing", updatedAt: iso(-1_000) }), NOW),
|
|
72
|
+
).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("status terminal aparece normalmente sem heartbeat", () => {
|
|
76
|
+
expect(describeRunStatus(run({ status: "review_done" }), NOW)).toEqual({
|
|
77
|
+
kind: "review_done",
|
|
78
|
+
label: "Revisão pronta",
|
|
79
|
+
timeout: false,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("correção também usa heartbeat para timeout", () => {
|
|
84
|
+
expect(
|
|
85
|
+
describeRunStatus(
|
|
86
|
+
run({
|
|
87
|
+
status: "fixing",
|
|
88
|
+
heartbeatAt: iso(-STATUS_TIMEOUT_MS - 1_000),
|
|
89
|
+
}),
|
|
90
|
+
NOW,
|
|
91
|
+
),
|
|
92
|
+
).toEqual({ kind: "timeout", label: "Timeout na correção", timeout: true });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
pingFindingsStatus,
|
|
8
8
|
readRunStatus,
|
|
9
9
|
statusAfterFindingsChange,
|
|
10
|
+
touchRunStatus,
|
|
10
11
|
writeRunStatus,
|
|
11
12
|
} from "../core/run-status";
|
|
12
13
|
import { buildRepoIdentity } from "../core/storage/repo-identity";
|
|
@@ -78,6 +79,7 @@ describe("run-status — storage", () => {
|
|
|
78
79
|
expect(first?.status).toBe("reviewing");
|
|
79
80
|
expect(first?.slug).toBe("feature-x");
|
|
80
81
|
expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
82
|
+
expect(first?.heartbeatAt).toBe("2026-01-01T00:00:00.000Z");
|
|
81
83
|
|
|
82
84
|
writeRunStatus(storage, reviewId, {
|
|
83
85
|
status: "review_done",
|
|
@@ -86,6 +88,7 @@ describe("run-status — storage", () => {
|
|
|
86
88
|
const second = readRunStatus(storage, reviewId);
|
|
87
89
|
expect(second?.status).toBe("review_done");
|
|
88
90
|
expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
91
|
+
expect(second?.heartbeatAt).toBeUndefined();
|
|
89
92
|
expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
|
|
90
93
|
|
|
91
94
|
writeRunStatus(storage, reviewId, {
|
|
@@ -96,6 +99,56 @@ describe("run-status — storage", () => {
|
|
|
96
99
|
expect(readRunStatus(storage, reviewId)?.startedAt).toBe(
|
|
97
100
|
"2026-01-01T00:20:00.000Z",
|
|
98
101
|
);
|
|
102
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
|
|
103
|
+
"2026-01-01T00:20:00.000Z",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("touch renova o heartbeat apenas em execução ativa", () => {
|
|
109
|
+
withStorage((storage, reviewId) => {
|
|
110
|
+
writeRunStatus(storage, reviewId, {
|
|
111
|
+
status: "reviewing",
|
|
112
|
+
resetStartedAt: true,
|
|
113
|
+
now: "2026-01-01T00:00:00.000Z",
|
|
114
|
+
});
|
|
115
|
+
touchRunStatus(storage, reviewId, "2026-01-01T00:05:00.000Z");
|
|
116
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
|
|
117
|
+
"2026-01-01T00:05:00.000Z",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
writeRunStatus(storage, reviewId, {
|
|
121
|
+
status: "review_done",
|
|
122
|
+
now: "2026-01-01T00:10:00.000Z",
|
|
123
|
+
});
|
|
124
|
+
touchRunStatus(storage, reviewId, "2026-01-01T00:15:00.000Z");
|
|
125
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("triagem em reviewing não renova o heartbeat; fix em andamento sim", () => {
|
|
130
|
+
withStorage((storage, reviewId) => {
|
|
131
|
+
writeRunStatus(storage, reviewId, {
|
|
132
|
+
status: "reviewing",
|
|
133
|
+
resetStartedAt: true,
|
|
134
|
+
now: "2026-01-01T00:00:00.000Z",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Triagem (decision/comment) mantém reviewing — não é execução.
|
|
138
|
+
pingFindingsStatus(storage, reviewId, stateOf([]));
|
|
139
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
|
|
140
|
+
"2026-01-01T00:00:00.000Z",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Correção em andamento renova o heartbeat.
|
|
144
|
+
pingFindingsStatus(
|
|
145
|
+
storage,
|
|
146
|
+
reviewId,
|
|
147
|
+
stateOf([findingState({ execState: "in_progress" })]),
|
|
148
|
+
);
|
|
149
|
+
const fixed = readRunStatus(storage, reviewId);
|
|
150
|
+
expect(fixed?.status).toBe("fixing");
|
|
151
|
+
expect(fixed?.heartbeatAt).not.toBe("2026-01-01T00:00:00.000Z");
|
|
99
152
|
});
|
|
100
153
|
});
|
|
101
154
|
|