@payconductor/houndsman 0.13.0 → 0.14.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 +6 -1
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/hunt-command.ts +20 -3
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/index.ts +3 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +8 -0
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +246 -12
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +233 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +27 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +243 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +31 -3
- package/src/core/defaults.ts +50 -4
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +347 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +13 -1
- package/src/core/github-review.ts +5 -2
- package/src/core/hunt/hunt-runner.ts +90 -0
- package/src/core/hunt/index.ts +2 -0
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +13 -1
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +8 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +60 -21
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +108 -278
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +16 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +29 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +36 -10
- package/src/skill/README.md +18 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +145 -58
- package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
- package/src/tests/ai-core.test.ts +46 -0
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/compare.test.ts +233 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/hunt-maki.test.ts +58 -0
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +106 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- package/src/skill/example-review.md +0 -66
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modelo de intenção (`business-summary.json`) e política de severidade dos
|
|
3
|
+
* findings de lógica/domínio.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { AstAnalysis } from "../core/ast/types";
|
|
8
|
+
import { defaultRenderBrief } from "../core/brief";
|
|
9
|
+
import { GENERAL_WATCHDOG_ID, generalWatchdog } from "../core/builtins";
|
|
10
|
+
import {
|
|
11
|
+
buildBusinessBrief,
|
|
12
|
+
buildBusinessSummary,
|
|
13
|
+
parseBusinessSummary,
|
|
14
|
+
} from "../core/business-summary";
|
|
15
|
+
import { normalizeFinding } from "../core/report-builder";
|
|
16
|
+
import { findWatchdog } from "../core/watchdog-registry";
|
|
17
|
+
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
18
|
+
|
|
19
|
+
const facts: AstAnalysis["facts"] = {
|
|
20
|
+
language: "typescript",
|
|
21
|
+
symbols: [],
|
|
22
|
+
imports: [],
|
|
23
|
+
exports: [],
|
|
24
|
+
decorators: [],
|
|
25
|
+
hasSyntaxErrors: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const delta = {
|
|
29
|
+
addedSymbols: [
|
|
30
|
+
{
|
|
31
|
+
name: "Contrato",
|
|
32
|
+
kind: "interface" as const,
|
|
33
|
+
exported: true,
|
|
34
|
+
startLine: 1,
|
|
35
|
+
endLine: 3,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "executar",
|
|
39
|
+
kind: "function" as const,
|
|
40
|
+
exported: true,
|
|
41
|
+
startLine: 5,
|
|
42
|
+
endLine: 9,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
removedSymbols: [],
|
|
46
|
+
modifiedSymbols: [],
|
|
47
|
+
changedSignatures: [],
|
|
48
|
+
addedImports: [],
|
|
49
|
+
removedImports: [],
|
|
50
|
+
changedExports: [],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const classification = {
|
|
54
|
+
files: {
|
|
55
|
+
"src/alpha/a.ts": { status: "modified" as const },
|
|
56
|
+
"src/beta/b.ts": { status: "modified" as const },
|
|
57
|
+
"src/alpha/old.ts": { status: "deleted" as const },
|
|
58
|
+
},
|
|
59
|
+
churn: {
|
|
60
|
+
"src/alpha/a.ts": { additions: 10, deletions: 2 },
|
|
61
|
+
"src/beta/b.ts": { additions: 1, deletions: 0 },
|
|
62
|
+
},
|
|
63
|
+
watchdogs: {
|
|
64
|
+
alpha: ["src/alpha/a.ts"],
|
|
65
|
+
beta: ["src/beta/b.ts"],
|
|
66
|
+
[GENERAL_WATCHDOG_ID]: ["src/alpha/a.ts", "src/beta/b.ts"],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const domains = [
|
|
71
|
+
{ id: "alpha", name: "Alpha" },
|
|
72
|
+
{ id: "beta", name: "Beta" },
|
|
73
|
+
{ id: GENERAL_WATCHDOG_ID, name: "Lógica e domínio (fluxo)" },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
describe("buildBusinessSummary", () => {
|
|
77
|
+
test("deriva fluxos por domínio (sem o general) e contratos do AST", () => {
|
|
78
|
+
const summary = buildBusinessSummary({
|
|
79
|
+
classification,
|
|
80
|
+
domains,
|
|
81
|
+
ast: new Map([["src/alpha/a.ts", { facts, delta }]]),
|
|
82
|
+
headSha: "abc123",
|
|
83
|
+
context: { objective: "Entregar o split" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(summary.source).toBe("deterministic");
|
|
87
|
+
expect(summary.intent).toBe("Entregar o split");
|
|
88
|
+
expect(summary.headSha).toBe("abc123");
|
|
89
|
+
expect(summary.flows.map((flow) => flow.name)).toEqual(["Alpha", "Beta"]);
|
|
90
|
+
expect(summary.flows[0]?.files).toEqual(["src/alpha/a.ts"]);
|
|
91
|
+
expect(summary.flows[0]?.expected).toContain("Contrato");
|
|
92
|
+
expect(summary.contracts).toEqual([
|
|
93
|
+
{
|
|
94
|
+
name: "Contrato",
|
|
95
|
+
sides: ["src/alpha/a.ts"],
|
|
96
|
+
expectation:
|
|
97
|
+
"Declaração `interface` nova — verificar se todos os lados do contrato a honram.",
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("sem contexto e sem AST, mantém o esqueleto vazio (nunca inventa)", () => {
|
|
103
|
+
const summary = buildBusinessSummary({ classification, domains });
|
|
104
|
+
expect(summary.intent).toBeUndefined();
|
|
105
|
+
expect(summary.contracts).toEqual([]);
|
|
106
|
+
expect(summary.invariants).toEqual([]);
|
|
107
|
+
expect(summary.flows).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("parseBusinessSummary", () => {
|
|
112
|
+
test("normaliza listas e preserva a fonte", () => {
|
|
113
|
+
const summary = parseBusinessSummary(
|
|
114
|
+
{
|
|
115
|
+
source: "reviewer",
|
|
116
|
+
intent: " Entregar Y ",
|
|
117
|
+
flows: [{ name: "Renovação", trigger: "job", files: ["src/a.ts"] }],
|
|
118
|
+
invariants: ["split sempre presente"],
|
|
119
|
+
contracts: [{ name: "AutomaticPix", sides: ["asaas", "processor"] }],
|
|
120
|
+
states: [{ name: "authorization", transitions: ["ativa → cancelada"] }],
|
|
121
|
+
},
|
|
122
|
+
"deterministic",
|
|
123
|
+
);
|
|
124
|
+
expect(summary?.source).toBe("reviewer");
|
|
125
|
+
expect(summary?.intent).toBe("Entregar Y");
|
|
126
|
+
expect(summary?.flows[0]?.name).toBe("Renovação");
|
|
127
|
+
expect(summary?.invariants).toEqual(["split sempre presente"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rejeita documento sem conteúdo útil", () => {
|
|
131
|
+
expect(parseBusinessSummary({})).toBeNull();
|
|
132
|
+
expect(parseBusinessSummary(null)).toBeNull();
|
|
133
|
+
expect(parseBusinessSummary({ flows: [{ name: "" }] })).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("corta itens acima do limite e ignora entradas inválidas", () => {
|
|
137
|
+
const summary = parseBusinessSummary({
|
|
138
|
+
intent: "x",
|
|
139
|
+
flows: [
|
|
140
|
+
{ name: "ok" },
|
|
141
|
+
{ name: "" },
|
|
142
|
+
{ name: "outro", files: ["a", 1, "b"] },
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
expect(summary?.flows.map((flow) => flow.name)).toEqual(["ok", "outro"]);
|
|
146
|
+
expect(summary?.flows[1]?.files).toEqual(["a", "b"]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("buildBusinessBrief", () => {
|
|
151
|
+
test("aponta o output e inclui o schema e o modelo determinístico", () => {
|
|
152
|
+
const summary = buildBusinessSummary({ classification, domains });
|
|
153
|
+
const brief = buildBusinessBrief({
|
|
154
|
+
projectName: "Fake",
|
|
155
|
+
branch: "main",
|
|
156
|
+
headSha: "abc123",
|
|
157
|
+
outputPath: "/tmp/work/review/business-summary.json",
|
|
158
|
+
business: summary,
|
|
159
|
+
files: [
|
|
160
|
+
{
|
|
161
|
+
path: "src/alpha/a.ts",
|
|
162
|
+
status: "modified",
|
|
163
|
+
ranges: [{ start: 3, end: 9 }],
|
|
164
|
+
symbols: ["Contrato"],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
docs: ["AGENTS.md"],
|
|
168
|
+
context: { objective: "Entregar o split" },
|
|
169
|
+
});
|
|
170
|
+
expect(brief).toContain("/tmp/work/review/business-summary.json");
|
|
171
|
+
expect(brief).toContain('"invariants"');
|
|
172
|
+
expect(brief).toContain("Modelo determinístico");
|
|
173
|
+
expect(brief).toContain("src/alpha/a.ts");
|
|
174
|
+
expect(brief).toContain("AGENTS.md");
|
|
175
|
+
expect(brief).toContain('source: "reviewer"');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("política de severidade (lógica/domínio)", () => {
|
|
180
|
+
const base = {
|
|
181
|
+
file: "src/alpha/a.ts",
|
|
182
|
+
line: 3,
|
|
183
|
+
message: "gap",
|
|
184
|
+
action: "corrigir",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
test("blocking sem cenário+evidência vira attention", () => {
|
|
188
|
+
const warnings: string[] = [];
|
|
189
|
+
const finding = normalizeFinding(
|
|
190
|
+
{
|
|
191
|
+
...base,
|
|
192
|
+
rule: "Domínio: renovação perde o split",
|
|
193
|
+
severity: "blocking",
|
|
194
|
+
},
|
|
195
|
+
(message) => warnings.push(message),
|
|
196
|
+
);
|
|
197
|
+
expect(finding.basis).toBe("domain");
|
|
198
|
+
expect(finding.severity).toBe("attention");
|
|
199
|
+
expect(warnings).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("com cenário e evidência, blocking é preservado", () => {
|
|
203
|
+
const finding = normalizeFinding({
|
|
204
|
+
...base,
|
|
205
|
+
rule: "Lógica: subscription sem authorize",
|
|
206
|
+
severity: "blocking",
|
|
207
|
+
scenario: "renovação de assinatura Pix",
|
|
208
|
+
evidence: "this.sendAutomaticPixPayment(data)",
|
|
209
|
+
});
|
|
210
|
+
expect(finding.basis).toBe("logic");
|
|
211
|
+
expect(finding.severity).toBe("blocking");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("regra explícita mantém a severidade declarada", () => {
|
|
215
|
+
const finding = normalizeFinding({
|
|
216
|
+
...base,
|
|
217
|
+
rule: "Nunca usar `any`.",
|
|
218
|
+
severity: "blocking",
|
|
219
|
+
});
|
|
220
|
+
expect(finding.basis).toBe("rule");
|
|
221
|
+
expect(finding.severity).toBe("blocking");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("watchdog general", () => {
|
|
226
|
+
test("resolvido como built-in mesmo sem config", () => {
|
|
227
|
+
const definition = findWatchdog(fakeHoundsmanConfig, GENERAL_WATCHDOG_ID);
|
|
228
|
+
expect(definition?.name).toContain("Lógica");
|
|
229
|
+
expect(definition?.allowEmergentFindings).toBe(true);
|
|
230
|
+
expect(definition?.fallback).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("brief inclui intenção, diff e obrigação de output", () => {
|
|
234
|
+
const summary = buildBusinessSummary({ classification, domains });
|
|
235
|
+
const markdown = defaultRenderBrief(
|
|
236
|
+
{
|
|
237
|
+
watchdogId: GENERAL_WATCHDOG_ID,
|
|
238
|
+
files: ["src/alpha/a.ts"],
|
|
239
|
+
statuses: { "src/alpha/a.ts": { status: "modified" as const } },
|
|
240
|
+
deleted: [],
|
|
241
|
+
branch: "main",
|
|
242
|
+
outputPath: "/tmp/findings/general.json",
|
|
243
|
+
outputFormatPath: "OUTPUT-FORMAT.md",
|
|
244
|
+
docs: [],
|
|
245
|
+
business: summary,
|
|
246
|
+
businessSummaryPath: "/tmp/business-summary.json",
|
|
247
|
+
snippets: [{ file: "src/alpha/a.ts", snippet: "@@ -1 +1 @@\n-a\n+b" }],
|
|
248
|
+
flowReview: true,
|
|
249
|
+
},
|
|
250
|
+
generalWatchdog,
|
|
251
|
+
).join("\n");
|
|
252
|
+
expect(markdown).toContain("## Modelo de intenção (negócio)");
|
|
253
|
+
expect(markdown).toContain("## Trechos alterados (diff)");
|
|
254
|
+
expect(markdown).toContain("## Obrigação de output (fluxo)");
|
|
255
|
+
expect(markdown).toContain("/tmp/business-summary.json");
|
|
256
|
+
expect(markdown).toContain("scenario");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparação A/B e identidade das variantes.
|
|
3
|
+
*
|
|
4
|
+
* Cobre o slug da run (URL/backend de arquivos), o diff de findings entre dois
|
|
5
|
+
* snapshots e o parse de `--variant` no comando default.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { runCompare } from "../core/commands/compare";
|
|
13
|
+
import { parseReviewArgs } from "../core/commands/review";
|
|
14
|
+
import {
|
|
15
|
+
buildComparison,
|
|
16
|
+
type ComparisonFinding,
|
|
17
|
+
countBySeverity,
|
|
18
|
+
type ReviewSnapshot,
|
|
19
|
+
renderComparison,
|
|
20
|
+
} from "../core/compare";
|
|
21
|
+
import type { FindingsStateFile } from "../core/findings-state";
|
|
22
|
+
import { reviewSlug, sanitizeVariant } from "../core/report-builder";
|
|
23
|
+
import { buildRepoIdentity } from "../core/storage/repo-identity";
|
|
24
|
+
import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
|
|
25
|
+
|
|
26
|
+
function finding(
|
|
27
|
+
fingerprint: string,
|
|
28
|
+
partial: Partial<ComparisonFinding> = {},
|
|
29
|
+
): ComparisonFinding {
|
|
30
|
+
return {
|
|
31
|
+
fingerprint,
|
|
32
|
+
rule: "regra",
|
|
33
|
+
severity: "attention",
|
|
34
|
+
file: "src/a.ts",
|
|
35
|
+
line: 10,
|
|
36
|
+
message: "mensagem",
|
|
37
|
+
watchdogs: ["alpha"],
|
|
38
|
+
...partial,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function snapshot(
|
|
43
|
+
label: string,
|
|
44
|
+
findings: ComparisonFinding[],
|
|
45
|
+
variant?: string,
|
|
46
|
+
): ReviewSnapshot {
|
|
47
|
+
return {
|
|
48
|
+
label,
|
|
49
|
+
slug: reviewSlug("feature/x", variant),
|
|
50
|
+
branch: "feature/x",
|
|
51
|
+
variant,
|
|
52
|
+
headSha: "deadbeef",
|
|
53
|
+
findings,
|
|
54
|
+
bySeverity: countBySeverity(findings),
|
|
55
|
+
coverage: { watchdogs: 2, files: 3, overflow: 0 },
|
|
56
|
+
durationMs: 60_000,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("reviewSlug / sanitizeVariant", () => {
|
|
61
|
+
test("run principal usa o slug da branch", () => {
|
|
62
|
+
expect(reviewSlug("feature/add-webhook")).toBe("feature-add-webhook");
|
|
63
|
+
expect(reviewSlug("feature/x", " ")).toBe("feature-x");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("variante ganha sufixo legível + hash estável", () => {
|
|
67
|
+
const baseline = reviewSlug("feature/x", "Baseline A");
|
|
68
|
+
const optimized = reviewSlug("feature/x", "Baseline B");
|
|
69
|
+
expect(baseline).toStartWith("feature-x--baseline-a-");
|
|
70
|
+
expect(baseline).toBe(reviewSlug("feature/x", "Baseline A"));
|
|
71
|
+
expect(baseline).not.toBe(optimized);
|
|
72
|
+
expect(sanitizeVariant(" A/B ")).toBe("a-b");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("buildComparison", () => {
|
|
77
|
+
const a = snapshot("A", [
|
|
78
|
+
finding("f1", { severity: "attention", file: "src/a.ts" }),
|
|
79
|
+
finding("f2", { severity: "suggestion", file: "src/b.ts", line: 2 }),
|
|
80
|
+
]);
|
|
81
|
+
const b = snapshot(
|
|
82
|
+
"B",
|
|
83
|
+
[
|
|
84
|
+
finding("f1", { severity: "blocking", file: "src/a.ts" }),
|
|
85
|
+
finding("f3", { severity: "blocking", file: "src/c.ts", line: 5 }),
|
|
86
|
+
],
|
|
87
|
+
"otimizado",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
test("separa novos, sumidos e mudança de severidade", () => {
|
|
91
|
+
const comparison = buildComparison(a, b);
|
|
92
|
+
expect(comparison.onlyB.map((item) => item.fingerprint)).toEqual(["f3"]);
|
|
93
|
+
expect(comparison.onlyA.map((item) => item.fingerprint)).toEqual(["f2"]);
|
|
94
|
+
expect(comparison.common).toHaveLength(1);
|
|
95
|
+
expect(comparison.severityChanged).toHaveLength(1);
|
|
96
|
+
expect(comparison.severityChanged[0].a.severity).toBe("attention");
|
|
97
|
+
expect(comparison.severityChanged[0].b.severity).toBe("blocking");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("conta por severidade", () => {
|
|
101
|
+
expect(a.bySeverity).toEqual({
|
|
102
|
+
blocking: 0,
|
|
103
|
+
attention: 1,
|
|
104
|
+
suggestion: 1,
|
|
105
|
+
nit: 0,
|
|
106
|
+
});
|
|
107
|
+
expect(b.bySeverity).toEqual({
|
|
108
|
+
blocking: 2,
|
|
109
|
+
attention: 0,
|
|
110
|
+
suggestion: 0,
|
|
111
|
+
nit: 0,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("renderiza a tabela e as listas", () => {
|
|
116
|
+
const markdown = renderComparison(buildComparison(a, b));
|
|
117
|
+
expect(markdown).toContain("# Comparação A/B — `feature/x`");
|
|
118
|
+
expect(markdown).toContain("| Findings (presentes) | 2 | 2 | 0 |");
|
|
119
|
+
expect(markdown).toContain("## Só em B (novos) (1)");
|
|
120
|
+
expect(markdown).toContain("## Só em A (não apareceram em B) (1)");
|
|
121
|
+
expect(markdown).toContain("## Severidade mudou (1)");
|
|
122
|
+
expect(markdown).toContain("## Só em B");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("--variant", () => {
|
|
127
|
+
test("é lido pelo parse do comando default", () => {
|
|
128
|
+
const options = parseReviewArgs([
|
|
129
|
+
"--branch",
|
|
130
|
+
"feature/x",
|
|
131
|
+
"--variant",
|
|
132
|
+
" baseline ",
|
|
133
|
+
]);
|
|
134
|
+
expect(options.variant).toBe("baseline");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("runCompare", () => {
|
|
138
|
+
test("lê as duas runs e grava o relatório na run B", () => {
|
|
139
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-compare-"));
|
|
140
|
+
const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
|
|
141
|
+
try {
|
|
142
|
+
const repo = buildRepoIdentity(null, dir);
|
|
143
|
+
const main = storage.getOrCreateReview({
|
|
144
|
+
repo,
|
|
145
|
+
branch: "feature/x",
|
|
146
|
+
slug: "feature-x",
|
|
147
|
+
});
|
|
148
|
+
const variant = storage.getOrCreateReview({
|
|
149
|
+
repo,
|
|
150
|
+
branch: "feature/x",
|
|
151
|
+
variant: "otimizado",
|
|
152
|
+
slug: "feature-x--otimizado-1a2b3c",
|
|
153
|
+
});
|
|
154
|
+
const state = (
|
|
155
|
+
findings: FindingsStateFile["findings"],
|
|
156
|
+
): FindingsStateFile => ({
|
|
157
|
+
version: 2,
|
|
158
|
+
branch: "feature/x",
|
|
159
|
+
slug: "feature-x",
|
|
160
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
161
|
+
order: Object.keys(findings),
|
|
162
|
+
findings,
|
|
163
|
+
});
|
|
164
|
+
const baseFinding = {
|
|
165
|
+
id: "ab12cd34ef",
|
|
166
|
+
fingerprint: "fp-1",
|
|
167
|
+
watchdogIds: ["alpha"],
|
|
168
|
+
rule: "regra",
|
|
169
|
+
severity: "attention" as const,
|
|
170
|
+
file: "src/a.ts",
|
|
171
|
+
line: 10,
|
|
172
|
+
message: "mensagem",
|
|
173
|
+
action: "ação",
|
|
174
|
+
anchor: null,
|
|
175
|
+
count: 1,
|
|
176
|
+
present: true,
|
|
177
|
+
decision: "pending" as const,
|
|
178
|
+
execState: "pending" as const,
|
|
179
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
180
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
181
|
+
history: [],
|
|
182
|
+
};
|
|
183
|
+
storage.writeFindings(main.id, state({ ab12cd34ef: baseFinding }));
|
|
184
|
+
storage.writeFindings(
|
|
185
|
+
variant.id,
|
|
186
|
+
state({
|
|
187
|
+
ab12cd34ef: baseFinding,
|
|
188
|
+
ff00aa11bb: {
|
|
189
|
+
...baseFinding,
|
|
190
|
+
id: "ff00aa11bb",
|
|
191
|
+
fingerprint: "fp-2",
|
|
192
|
+
rule: "outra",
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
storage.writeArtifact(main.id, {
|
|
197
|
+
name: "manifest.json",
|
|
198
|
+
kind: "manifest",
|
|
199
|
+
content: JSON.stringify({
|
|
200
|
+
watchdogs: [{ files: ["src/a.ts"], overflow: 0 }],
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
storage.writeArtifact(variant.id, {
|
|
204
|
+
name: "prepare-report.json",
|
|
205
|
+
kind: "prepare",
|
|
206
|
+
content: JSON.stringify({ durationMs: 1_000, stages: [] }),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = runCompare(storage, repo, {
|
|
210
|
+
branch: "feature/x",
|
|
211
|
+
variantB: "otimizado",
|
|
212
|
+
});
|
|
213
|
+
expect(result.b.id).toBe(variant.id);
|
|
214
|
+
expect(result.markdown).toContain("Só em B (novos) (1)");
|
|
215
|
+
expect(
|
|
216
|
+
storage.readArtifact(variant.id, result.artifacts.markdown)?.kind,
|
|
217
|
+
).toBe("compare");
|
|
218
|
+
expect(
|
|
219
|
+
storage.readArtifact(variant.id, result.artifacts.json)?.content,
|
|
220
|
+
).toContain("onlyB");
|
|
221
|
+
} finally {
|
|
222
|
+
storage.close();
|
|
223
|
+
rmSync(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("vazio é rejeitado", () => {
|
|
229
|
+
expect(() => parseReviewArgs(["--variant", " "])).toThrow(
|
|
230
|
+
"--variant não pode ser vazio",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -30,6 +30,7 @@ export class FakeModelProvider implements ModelProvider {
|
|
|
30
30
|
constructor(
|
|
31
31
|
private readonly handler: FakeHandler,
|
|
32
32
|
modelId = "fake-model",
|
|
33
|
+
private readonly finishReason = "stop",
|
|
33
34
|
) {
|
|
34
35
|
this.modelId = modelId;
|
|
35
36
|
}
|
|
@@ -41,7 +42,7 @@ export class FakeModelProvider implements ModelProvider {
|
|
|
41
42
|
return {
|
|
42
43
|
output: output as T,
|
|
43
44
|
usage: fakeUsage(),
|
|
44
|
-
finishReason:
|
|
45
|
+
finishReason: this.finishReason,
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -244,6 +244,64 @@ describe("HuntRunner", () => {
|
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
+
test("pré-passe de intenção grava o modelo antes dos watchdogs", async () => {
|
|
248
|
+
const staging = makeWork(["alpha"]);
|
|
249
|
+
staging.work.writeBusinessBrief("# Brief de intenção");
|
|
250
|
+
try {
|
|
251
|
+
let intentCalls = 0;
|
|
252
|
+
const runner = new HuntRunner({
|
|
253
|
+
work: staging.work,
|
|
254
|
+
root: staging.root,
|
|
255
|
+
projectName: "Proj",
|
|
256
|
+
model: "deepseek/deepseek-v4-pro",
|
|
257
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
258
|
+
apiKey: "sk",
|
|
259
|
+
concurrency: 1,
|
|
260
|
+
timeoutMs: 10_000,
|
|
261
|
+
retries: 0,
|
|
262
|
+
runner: {
|
|
263
|
+
run: async (request) => {
|
|
264
|
+
if (request.prompt.includes("passo de intenção")) {
|
|
265
|
+
intentCalls++;
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
text: JSON.stringify({
|
|
269
|
+
intent: "Entregar o split",
|
|
270
|
+
flows: [
|
|
271
|
+
{ name: "Renovação", files: ["src/alpha/a.ts"] },
|
|
272
|
+
],
|
|
273
|
+
invariants: ["split sempre presente"],
|
|
274
|
+
contracts: [],
|
|
275
|
+
states: [],
|
|
276
|
+
}),
|
|
277
|
+
metrics: { durationMs: 5 },
|
|
278
|
+
stderr: "",
|
|
279
|
+
exitCode: 0,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
text: reportJson("alpha"),
|
|
285
|
+
metrics: { durationMs: 5 },
|
|
286
|
+
stderr: "",
|
|
287
|
+
exitCode: 0,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const report = await runner.run(["alpha"]);
|
|
294
|
+
expect(intentCalls).toBe(1);
|
|
295
|
+
expect(report.business).toBe("reviewer");
|
|
296
|
+
const summary = staging.work.readBusinessSummary();
|
|
297
|
+
expect(summary?.source).toBe("reviewer");
|
|
298
|
+
expect(summary?.intent).toBe("Entregar o split");
|
|
299
|
+
expect(summary?.invariants).toEqual(["split sempre presente"]);
|
|
300
|
+
} finally {
|
|
301
|
+
staging.cleanup();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
247
305
|
test("repete uma vez quando a saída não tem JSON válido", async () => {
|
|
248
306
|
const staging = makeWork(["alpha"]);
|
|
249
307
|
try {
|
|
@@ -116,11 +116,11 @@ describe("parseInline", () => {
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
test("destaca caminhos de arquivo no texto corrido", () => {
|
|
119
|
-
expect(parseInline("veja src/foo/bar.ts e
|
|
119
|
+
expect(parseInline("veja src/foo/bar.ts e manifest.json")).toEqual([
|
|
120
120
|
{ type: "text", text: "veja " },
|
|
121
121
|
{ type: "path", text: "src/foo/bar.ts" },
|
|
122
122
|
{ type: "text", text: " e " },
|
|
123
|
-
{ type: "path", text: "
|
|
123
|
+
{ type: "path", text: "manifest.json" },
|
|
124
124
|
]);
|
|
125
125
|
});
|
|
126
126
|
});
|
|
@@ -128,7 +128,7 @@ describe("parseInline", () => {
|
|
|
128
128
|
describe("isFilePath", () => {
|
|
129
129
|
test("reconhece arquivos e caminhos, sem confundir texto comum", () => {
|
|
130
130
|
expect(isFilePath("src/foo/bar.ts")).toBe(true);
|
|
131
|
-
expect(isFilePath("
|
|
131
|
+
expect(isFilePath("manifest.json")).toBe(true);
|
|
132
132
|
expect(isFilePath("./scripts/ci.sh")).toBe(true);
|
|
133
133
|
expect(isFilePath("M/D")).toBe(false);
|
|
134
134
|
expect(isFilePath("duas palavras.ts")).toBe(false);
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
formatDuration,
|
|
11
|
+
Logger,
|
|
12
|
+
ParallelPhaseReporter,
|
|
13
|
+
PhaseReporter,
|
|
14
|
+
ProgressBoard,
|
|
15
|
+
} from "../core/logging";
|
|
10
16
|
|
|
11
17
|
function memoryStream(): { stream: NodeJS.WritableStream; text: () => string } {
|
|
12
18
|
let content = "";
|
|
@@ -64,6 +70,28 @@ describe("PhaseReporter", () => {
|
|
|
64
70
|
});
|
|
65
71
|
});
|
|
66
72
|
|
|
73
|
+
describe("ParallelPhaseReporter", () => {
|
|
74
|
+
test("em pipe aceita fases concorrentes e loga cada uma", () => {
|
|
75
|
+
const { logger, out } = loggerFor(false);
|
|
76
|
+
const reporter = new ParallelPhaseReporter(logger);
|
|
77
|
+
reporter.begin("context", "Resumindo o contexto");
|
|
78
|
+
reporter.begin("business", "Reconstruindo a intenção");
|
|
79
|
+
reporter.end("context", "Contexto resumido", 50);
|
|
80
|
+
reporter.end("business", "Intenção reconstruída", 80);
|
|
81
|
+
reporter.stop();
|
|
82
|
+
const text = out.text();
|
|
83
|
+
expect(text).toContain("→ Resumindo o contexto");
|
|
84
|
+
expect(text).toContain("→ Reconstruindo a intenção");
|
|
85
|
+
expect(text).toContain("✔ Contexto resumido (50ms)");
|
|
86
|
+
expect(text).toContain("✔ Intenção reconstruída (80ms)");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("stop sem fases ativas não lança", () => {
|
|
90
|
+
const { logger } = loggerFor(false);
|
|
91
|
+
expect(() => new ParallelPhaseReporter(logger).stop()).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
67
95
|
describe("ProgressBoard", () => {
|
|
68
96
|
test("em pipe loga início/fim e não usa ANSI", () => {
|
|
69
97
|
const { logger, out, err } = loggerFor(false);
|