@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Propagação mecânica — cluster de micro-alterações e lacunas de importadores.
|
|
3
|
+
*
|
|
4
|
+
* Congela a heurística usada no brief para não ler N diffs de uma linha e para
|
|
5
|
+
* apontar contratos cujos importadores ficaram fora do diff.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { AstAnalysis, AstImpact } from "../core/ast/types";
|
|
10
|
+
import { detectPropagation, detectPropagationGaps } from "../core/propagation";
|
|
11
|
+
import type { ChurnMap } from "../core/types";
|
|
12
|
+
|
|
13
|
+
const files = Array.from({ length: 7 }, (_, index) => `src/f${index}.ts`);
|
|
14
|
+
|
|
15
|
+
function patchFor(line: string): Map<string, string> {
|
|
16
|
+
return new Map(
|
|
17
|
+
files.map((file) => [file, `@@ -1 +1 @@\n- await pay(charge);\n+${line}`]),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function churnFor(extra: ChurnMap = {}): ChurnMap {
|
|
22
|
+
const base: ChurnMap = Object.fromEntries(
|
|
23
|
+
files.map((file) => [file, { additions: 1, deletions: 1 }]),
|
|
24
|
+
);
|
|
25
|
+
return { ...base, ...extra };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("detectPropagation", () => {
|
|
29
|
+
test("agrupa o padrão dominante e isola os outliers", () => {
|
|
30
|
+
const patch = patchFor(" await pay(charge, true);");
|
|
31
|
+
patch.set(
|
|
32
|
+
"src/f6.ts",
|
|
33
|
+
"@@ -1 +1 @@\n- await pay(charge);\n+ await pay(charge, false);",
|
|
34
|
+
);
|
|
35
|
+
const cluster = detectPropagation(files, patch, churnFor());
|
|
36
|
+
expect(cluster?.files).toEqual(files.slice(0, 6));
|
|
37
|
+
expect(cluster?.lines).toEqual(["await pay(charge, true);"]);
|
|
38
|
+
expect(cluster?.outliers).toEqual([
|
|
39
|
+
{ file: "src/f6.ts", lines: ["await pay(charge, false);"] },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("ignora arquivos com churn acima do limite de micro-alteração", () => {
|
|
44
|
+
const patch = patchFor(" await pay(charge, true);");
|
|
45
|
+
patch.set("src/big.ts", "+ await pay(charge, true);");
|
|
46
|
+
const cluster = detectPropagation([...files, "src/big.ts"], patch, {
|
|
47
|
+
...churnFor(),
|
|
48
|
+
"src/big.ts": { additions: 40, deletions: 0 },
|
|
49
|
+
});
|
|
50
|
+
expect(cluster?.files).not.toContain("src/big.ts");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("sem grupo dominante devolve null", () => {
|
|
54
|
+
const patch = new Map(
|
|
55
|
+
files.slice(0, 3).map((file) => [file, "+ await pay(charge, true);"]),
|
|
56
|
+
);
|
|
57
|
+
expect(detectPropagation(files.slice(0, 3), patch, churnFor())).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("detectPropagationGaps", () => {
|
|
62
|
+
const analysis = {
|
|
63
|
+
facts: {},
|
|
64
|
+
delta: {
|
|
65
|
+
addedSymbols: [],
|
|
66
|
+
removedSymbols: [],
|
|
67
|
+
modifiedSymbols: [],
|
|
68
|
+
changedSignatures: [
|
|
69
|
+
{
|
|
70
|
+
name: "pay",
|
|
71
|
+
before: "pay(charge)",
|
|
72
|
+
after: "pay(charge, x)",
|
|
73
|
+
exported: true,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
changedExports: [],
|
|
77
|
+
addedImports: [],
|
|
78
|
+
removedImports: [],
|
|
79
|
+
},
|
|
80
|
+
} as unknown as AstAnalysis;
|
|
81
|
+
const impact = {
|
|
82
|
+
affectedFiles: [],
|
|
83
|
+
importers: {
|
|
84
|
+
"src/types.ts": ["src/a.ts", "src/b.ts"],
|
|
85
|
+
"src/other.ts": ["src/c.ts"],
|
|
86
|
+
},
|
|
87
|
+
exportedChanges: [],
|
|
88
|
+
} as unknown as AstImpact;
|
|
89
|
+
|
|
90
|
+
test("lista só importadores que não estão no diff", () => {
|
|
91
|
+
const gaps = detectPropagationGaps(
|
|
92
|
+
["src/types.ts", "src/other.ts"],
|
|
93
|
+
new Map([["src/types.ts", analysis]]),
|
|
94
|
+
impact,
|
|
95
|
+
new Set(["src/a.ts"]),
|
|
96
|
+
);
|
|
97
|
+
expect(gaps).toEqual([{ file: "src/types.ts", importers: ["src/b.ts"] }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("ignora mudança que não toca contrato exportado", () => {
|
|
101
|
+
const internal = {
|
|
102
|
+
...analysis,
|
|
103
|
+
delta: {
|
|
104
|
+
...analysis.delta,
|
|
105
|
+
changedSignatures: [
|
|
106
|
+
{ name: "pay", before: "pay(a)", after: "pay(b)", exported: false },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
} as unknown as AstAnalysis;
|
|
110
|
+
expect(
|
|
111
|
+
detectPropagationGaps(
|
|
112
|
+
["src/types.ts"],
|
|
113
|
+
new Map([["src/types.ts", internal]]),
|
|
114
|
+
impact,
|
|
115
|
+
new Set(),
|
|
116
|
+
),
|
|
117
|
+
).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -95,6 +95,63 @@ describe("renderReviewResume", () => {
|
|
|
95
95
|
expect(md).toContain("Sem mudanças estruturais detectadas nesta revisão.");
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("inclui cobertura por domínio, candidatos e conclusão", () => {
|
|
99
|
+
const md = renderReviewResume({
|
|
100
|
+
branch: "feat/x",
|
|
101
|
+
date: "2026-10-17",
|
|
102
|
+
classification,
|
|
103
|
+
findings: [
|
|
104
|
+
finding({ severity: "blocking" }),
|
|
105
|
+
finding({ severity: "attention", file: "src/b.ts" }),
|
|
106
|
+
],
|
|
107
|
+
domains: [
|
|
108
|
+
{
|
|
109
|
+
id: "alpha",
|
|
110
|
+
name: "Alpha",
|
|
111
|
+
findings: 1,
|
|
112
|
+
reviewedFiles: 1,
|
|
113
|
+
expectedFiles: 2,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "beta",
|
|
117
|
+
name: "Beta",
|
|
118
|
+
findings: 1,
|
|
119
|
+
reviewedFiles: 1,
|
|
120
|
+
expectedFiles: 1,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
candidates: [
|
|
124
|
+
{
|
|
125
|
+
watchdogId: "alpha",
|
|
126
|
+
rule: "Suspeita X",
|
|
127
|
+
severity: "attention",
|
|
128
|
+
file: "src/c.ts",
|
|
129
|
+
confidence: 0.7,
|
|
130
|
+
questionKey: "q1",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
watchdogId: "alpha",
|
|
134
|
+
rule: "regra",
|
|
135
|
+
severity: "attention",
|
|
136
|
+
file: "src/a.ts",
|
|
137
|
+
confidence: 0.7,
|
|
138
|
+
questionKey: "q2",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(md).toContain("## Cobertura por domínio");
|
|
144
|
+
expect(md).toContain("| Alpha | 1 | 1/2 | — | 1 |");
|
|
145
|
+
expect(md).toContain("| Beta | 1 | 1/1 | — | — |");
|
|
146
|
+
expect(md).toContain("## Candidatos não verificados (1)");
|
|
147
|
+
expect(md).toContain("Suspeita X");
|
|
148
|
+
expect(md).not.toContain("`src/a.ts` ·");
|
|
149
|
+
expect(md).toContain("## Conclusão");
|
|
150
|
+
expect(md).toContain(
|
|
151
|
+
"**Resumo:** 1 bloqueantes · 1 atenção · 0 sugestões · 0 nits.",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
98
155
|
test("respeita o teto de caracteres", () => {
|
|
99
156
|
const findings = Array.from({ length: 40 }, (_, index) =>
|
|
100
157
|
finding({
|
|
@@ -124,6 +181,8 @@ describe("renderReviewResume", () => {
|
|
|
124
181
|
});
|
|
125
182
|
|
|
126
183
|
expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
|
|
127
|
-
|
|
184
|
+
// O corte acontece no texto narrativo; tabelas e conclusão seguem inteiras.
|
|
185
|
+
expect(md).toContain("\n…");
|
|
186
|
+
expect(md).toContain("## Conclusão");
|
|
128
187
|
});
|
|
129
188
|
});
|
|
@@ -256,7 +256,7 @@ describe("prepare/finalize — mesmo commit", () => {
|
|
|
256
256
|
};
|
|
257
257
|
|
|
258
258
|
let ctx = reviewContext(storage, record);
|
|
259
|
-
await prepare(
|
|
259
|
+
await prepare(ctx, fakeHoundsmanConfig, opts);
|
|
260
260
|
expect(storage.getReview(record.id)?.status).toBe("reviewing");
|
|
261
261
|
|
|
262
262
|
writeFileSync(
|
|
@@ -281,30 +281,46 @@ describe("prepare/finalize — mesmo commit", () => {
|
|
|
281
281
|
2,
|
|
282
282
|
)}\n`,
|
|
283
283
|
);
|
|
284
|
-
|
|
284
|
+
writeFileSync(
|
|
285
|
+
ctx.work.findingFile("general"),
|
|
286
|
+
`${JSON.stringify(
|
|
287
|
+
{
|
|
288
|
+
watchdogId: "general",
|
|
289
|
+
reviewedFiles: ["src/alpha/a.ts"],
|
|
290
|
+
skipped: [],
|
|
291
|
+
findings: [],
|
|
292
|
+
},
|
|
293
|
+
null,
|
|
294
|
+
2,
|
|
295
|
+
)}\n`,
|
|
296
|
+
);
|
|
297
|
+
finalize(ctx, fakeHoundsmanConfig, opts);
|
|
285
298
|
|
|
286
299
|
const classificationBefore = storage.readArtifact(
|
|
287
300
|
record.id,
|
|
288
301
|
"classification.json",
|
|
289
302
|
)?.content;
|
|
290
|
-
const reportBefore = storage.readArtifact(
|
|
303
|
+
const reportBefore = storage.readArtifact(
|
|
304
|
+
record.id,
|
|
305
|
+
"review-resume.md",
|
|
306
|
+
)?.content;
|
|
291
307
|
const findingsBefore = JSON.stringify(
|
|
292
308
|
storage.readFindings(record.id)?.findings,
|
|
293
309
|
);
|
|
294
310
|
const reviewedBefore = storage.getReview(record.id)?.reviewedSha;
|
|
295
311
|
expect(classificationBefore).toBeTruthy();
|
|
296
|
-
expect(reportBefore).toContain("
|
|
312
|
+
expect(reportBefore).toContain("## Cobertura por domínio");
|
|
297
313
|
|
|
298
314
|
// Mesmo commit: prepare vira no-op e nada é reescrito.
|
|
299
315
|
ctx = reviewContext(storage, storage.getReview(record.id) ?? record);
|
|
300
|
-
const again = await prepare(
|
|
316
|
+
const again = await prepare(ctx, fakeHoundsmanConfig, opts);
|
|
301
317
|
expect(again.messages.join("\n")).toContain(
|
|
302
318
|
"Nenhuma alteração desde a última revisão",
|
|
303
319
|
);
|
|
304
320
|
expect(storage.readArtifact(record.id, "classification.json")?.content).toBe(
|
|
305
321
|
classificationBefore,
|
|
306
322
|
);
|
|
307
|
-
expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
|
|
323
|
+
expect(storage.readArtifact(record.id, "review-resume.md")?.content).toBe(
|
|
308
324
|
reportBefore,
|
|
309
325
|
);
|
|
310
326
|
expect(storage.getReview(record.id)?.status).toBe("review_done");
|
|
@@ -312,13 +328,12 @@ describe("prepare/finalize — mesmo commit", () => {
|
|
|
312
328
|
|
|
313
329
|
// Finalize sem ciclo preparado: no-op (não degrada o relatório).
|
|
314
330
|
const finalizedAgain = finalize(
|
|
315
|
-
opts,
|
|
316
|
-
"main",
|
|
317
|
-
fakeHoundsmanConfig,
|
|
318
331
|
reviewContext(storage, storage.getReview(record.id) ?? record),
|
|
332
|
+
fakeHoundsmanConfig,
|
|
333
|
+
opts,
|
|
319
334
|
);
|
|
320
335
|
expect(finalizedAgain.messages.join("\n")).toContain("Nada a finalizar");
|
|
321
|
-
expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
|
|
336
|
+
expect(storage.readArtifact(record.id, "review-resume.md")?.content).toBe(
|
|
322
337
|
reportBefore,
|
|
323
338
|
);
|
|
324
339
|
expect(JSON.stringify(storage.readFindings(record.id)?.findings)).toBe(
|
|
@@ -364,10 +379,9 @@ describe("prepare sem alterações", () => {
|
|
|
364
379
|
};
|
|
365
380
|
|
|
366
381
|
const result = await prepare(
|
|
367
|
-
opts,
|
|
368
|
-
"main",
|
|
369
|
-
fakeHoundsmanConfig,
|
|
370
382
|
reviewContext(storage, record),
|
|
383
|
+
fakeHoundsmanConfig,
|
|
384
|
+
opts,
|
|
371
385
|
);
|
|
372
386
|
expect(result.messages.join("\n")).toContain("nenhuma run criada");
|
|
373
387
|
expect(storage.getReview(record.id)).toBeNull();
|
|
@@ -384,12 +398,7 @@ describe("prepare sem alterações", () => {
|
|
|
384
398
|
kind: "context",
|
|
385
399
|
content: "{}\n",
|
|
386
400
|
});
|
|
387
|
-
await prepare(
|
|
388
|
-
opts,
|
|
389
|
-
"main",
|
|
390
|
-
fakeHoundsmanConfig,
|
|
391
|
-
reviewContext(storage, kept),
|
|
392
|
-
);
|
|
401
|
+
await prepare(reviewContext(storage, kept), fakeHoundsmanConfig, opts);
|
|
393
402
|
expect(storage.getReview(kept.id)?.status).toBe("review_done");
|
|
394
403
|
expect(storage.readArtifact(kept.id, "context.json")).not.toBeNull();
|
|
395
404
|
} finally {
|
|
@@ -432,20 +441,20 @@ describe("prepare — progresso", () => {
|
|
|
432
441
|
};
|
|
433
442
|
|
|
434
443
|
const events: PrepareProgressEvent[] = [];
|
|
435
|
-
await prepare(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
fakeHoundsmanConfig,
|
|
439
|
-
reviewContext(storage, record),
|
|
440
|
-
undefined,
|
|
441
|
-
undefined,
|
|
442
|
-
(event) => events.push(event),
|
|
443
|
-
);
|
|
444
|
+
await prepare(reviewContext(storage, record), fakeHoundsmanConfig, opts, {
|
|
445
|
+
onProgress: (event) => events.push(event),
|
|
446
|
+
});
|
|
444
447
|
|
|
445
448
|
const starts = events
|
|
446
449
|
.filter((event) => event.type === "start")
|
|
447
450
|
.map((event) => event.stage);
|
|
448
|
-
expect(starts).toEqual([
|
|
451
|
+
expect(starts).toEqual([
|
|
452
|
+
"diff",
|
|
453
|
+
"classify",
|
|
454
|
+
"business",
|
|
455
|
+
"briefs",
|
|
456
|
+
"manifest",
|
|
457
|
+
]);
|
|
449
458
|
const done = events.filter((event) => event.type === "done");
|
|
450
459
|
expect(done.map((event) => event.stage)).toEqual(starts);
|
|
451
460
|
for (const event of done) {
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
} from "../core/config";
|
|
15
15
|
import { parseDiffIndex, parseNumstat } from "../core/diff-source";
|
|
16
16
|
import { discoverDocs } from "../core/doc-discoverer";
|
|
17
|
+
import { riskOf } from "../core/domain/budget";
|
|
18
|
+
import { coverageOf, unaccountedFiles } from "../core/engine/finalize-pipeline";
|
|
19
|
+
import { overlapMessage } from "../core/engine/prepare-pipeline";
|
|
17
20
|
import {
|
|
18
21
|
type ClassificationResult,
|
|
19
22
|
churnOf,
|
|
@@ -37,8 +40,6 @@ import {
|
|
|
37
40
|
dedupeFindings,
|
|
38
41
|
parseContextSummary,
|
|
39
42
|
parseWatchdogReport,
|
|
40
|
-
renderDomainReport,
|
|
41
|
-
renderReport,
|
|
42
43
|
sanitizeBranch,
|
|
43
44
|
type WatchdogReport,
|
|
44
45
|
} from "../core/report-builder";
|
|
@@ -451,6 +452,83 @@ describe("triage", () => {
|
|
|
451
452
|
expect(overflow).toEqual(["a.ts"]);
|
|
452
453
|
});
|
|
453
454
|
|
|
455
|
+
test("prioridade soma ao churn (linhas equivalentes) sem virar tier rígido", () => {
|
|
456
|
+
const priorities = [{ pattern: /^server\/db\//, bonus: 300 }];
|
|
457
|
+
const files = ["client/big.ts", "server/db/small.ts"];
|
|
458
|
+
expect(
|
|
459
|
+
sortByRisk(
|
|
460
|
+
files,
|
|
461
|
+
{
|
|
462
|
+
"client/big.ts": { additions: 200, deletions: 0 },
|
|
463
|
+
"server/db/small.ts": { additions: 10, deletions: 0 },
|
|
464
|
+
},
|
|
465
|
+
priorities,
|
|
466
|
+
),
|
|
467
|
+
).toEqual(["server/db/small.ts", "client/big.ts"]);
|
|
468
|
+
// Churn puro continua vencendo quando é muito maior (400 > 10 + 300).
|
|
469
|
+
expect(
|
|
470
|
+
sortByRisk(
|
|
471
|
+
files,
|
|
472
|
+
{
|
|
473
|
+
"client/big.ts": { additions: 400, deletions: 0 },
|
|
474
|
+
"server/db/small.ts": { additions: 10, deletions: 0 },
|
|
475
|
+
},
|
|
476
|
+
priorities,
|
|
477
|
+
),
|
|
478
|
+
).toEqual(["client/big.ts", "server/db/small.ts"]);
|
|
479
|
+
// Sem prioridade configurada, comportamento antigo.
|
|
480
|
+
expect(
|
|
481
|
+
sortByRisk(files, {
|
|
482
|
+
"client/big.ts": { additions: 200, deletions: 0 },
|
|
483
|
+
"server/db/small.ts": { additions: 10, deletions: 0 },
|
|
484
|
+
}),
|
|
485
|
+
).toEqual(["client/big.ts", "server/db/small.ts"]);
|
|
486
|
+
// O orçamento respeita a prioridade.
|
|
487
|
+
expect(
|
|
488
|
+
selectByBudget(
|
|
489
|
+
files,
|
|
490
|
+
{
|
|
491
|
+
"client/big.ts": { additions: 200, deletions: 0 },
|
|
492
|
+
"server/db/small.ts": { additions: 10, deletions: 0 },
|
|
493
|
+
},
|
|
494
|
+
1,
|
|
495
|
+
priorities,
|
|
496
|
+
).selected,
|
|
497
|
+
).toEqual(["server/db/small.ts"]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("maxRiskFromChurn satura o superchurn de caminho com baixa criticidade", () => {
|
|
501
|
+
const priorities = [
|
|
502
|
+
{ pattern: /^server\/db\//, bonus: 300 },
|
|
503
|
+
{ pattern: /^client\//, maxRiskFromChurn: 120 },
|
|
504
|
+
];
|
|
505
|
+
const files = ["client/table.tsx", "server/db/migration.ts"];
|
|
506
|
+
const churn = {
|
|
507
|
+
"client/table.tsx": { additions: 1900, deletions: 100 },
|
|
508
|
+
"server/db/migration.ts": { additions: 20, deletions: 0 },
|
|
509
|
+
};
|
|
510
|
+
// 2.000 linhas de UI "boba" viram 120; 20 linhas de db valem 320.
|
|
511
|
+
expect(riskOf("client/table.tsx", churn, priorities)).toBe(120);
|
|
512
|
+
expect(sortByRisk(files, churn, priorities)).toEqual([
|
|
513
|
+
"server/db/migration.ts",
|
|
514
|
+
"client/table.tsx",
|
|
515
|
+
]);
|
|
516
|
+
// Abaixo do teto, o caminho mantém o peso natural do churn.
|
|
517
|
+
expect(
|
|
518
|
+
riskOf(
|
|
519
|
+
"client/table.tsx",
|
|
520
|
+
{
|
|
521
|
+
"client/table.tsx": { additions: 15, deletions: 0 },
|
|
522
|
+
},
|
|
523
|
+
priorities,
|
|
524
|
+
),
|
|
525
|
+
).toBe(15);
|
|
526
|
+
// Sem teto configurado, o churn puro continua vencendo.
|
|
527
|
+
expect(
|
|
528
|
+
riskOf("client/table.tsx", churn, [{ pattern: /^server\/db\//, bonus: 300 }]),
|
|
529
|
+
).toBe(2000);
|
|
530
|
+
});
|
|
531
|
+
|
|
454
532
|
test("maxFiles não-positivo desativa o orçamento", () => {
|
|
455
533
|
const { selected, overflow } = selectByBudget(["a.ts", "b.ts"], churn, 0);
|
|
456
534
|
expect(selected).toHaveLength(2);
|
|
@@ -572,196 +650,35 @@ describe("report-builder", () => {
|
|
|
572
650
|
expect(merged[0].message).toBe("maior");
|
|
573
651
|
});
|
|
574
652
|
|
|
575
|
-
test("
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
expect(renderReport("main", reports, [], {})).toContain("✅ Aprovado");
|
|
581
|
-
|
|
582
|
-
const blockers = renderReport(
|
|
583
|
-
"main",
|
|
584
|
-
reports,
|
|
585
|
-
[
|
|
586
|
-
{
|
|
587
|
-
rule: "Regra bloqueante",
|
|
588
|
-
severity: "blocking",
|
|
589
|
-
file: "s.ts",
|
|
590
|
-
message: "m",
|
|
591
|
-
action: "a",
|
|
592
|
-
watchdogs: ["alpha"],
|
|
593
|
-
},
|
|
594
|
-
],
|
|
595
|
-
{},
|
|
596
|
-
);
|
|
597
|
-
expect(blockers).toContain("❌ Mudanças Necessárias");
|
|
598
|
-
expect(blockers).toContain("## ❌ blocking");
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
test("renderReport mostra cobertura pendente", () => {
|
|
602
|
-
const reports: WatchdogReport[] = [
|
|
603
|
-
{ watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
|
|
604
|
-
];
|
|
605
|
-
const md = renderReport("main", reports, [], {
|
|
606
|
-
classified: { alpha: ["s.ts", "t.ts"] },
|
|
607
|
-
});
|
|
608
|
-
expect(md).toContain("faltam 1");
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
test("renderReport usa o resolver de nome injetado", () => {
|
|
612
|
-
const reports: WatchdogReport[] = [
|
|
613
|
-
{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
|
|
614
|
-
];
|
|
615
|
-
const md = renderReport("main", reports, [], {}, undefined, undefined, (id) =>
|
|
616
|
-
id === "alpha" ? "Watchdog Alpha" : id,
|
|
617
|
-
);
|
|
618
|
-
expect(md).toContain("Watchdog Alpha");
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
test("renderReport agrupa achados por arquivo", () => {
|
|
622
|
-
const merged = [
|
|
623
|
-
{
|
|
624
|
-
rule: "R1",
|
|
625
|
-
severity: "blocking" as const,
|
|
626
|
-
file: "a.ts",
|
|
627
|
-
line: 1,
|
|
628
|
-
message: "m",
|
|
629
|
-
action: "a",
|
|
630
|
-
watchdogs: ["alpha"],
|
|
631
|
-
},
|
|
632
|
-
{
|
|
633
|
-
rule: "R2",
|
|
634
|
-
severity: "blocking" as const,
|
|
635
|
-
file: "b.ts",
|
|
636
|
-
line: 2,
|
|
637
|
-
message: "m",
|
|
638
|
-
action: "a",
|
|
639
|
-
watchdogs: ["alpha"],
|
|
640
|
-
},
|
|
641
|
-
];
|
|
642
|
-
const md = renderReport(
|
|
643
|
-
"main",
|
|
644
|
-
[{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
|
|
645
|
-
merged,
|
|
646
|
-
{},
|
|
647
|
-
);
|
|
648
|
-
expect(md).toContain("### `a.ts`");
|
|
649
|
-
expect(md).toContain("### `b.ts`");
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
test("renderReport indexa os relatórios por domínio", () => {
|
|
653
|
-
const md = renderReport(
|
|
654
|
-
"main",
|
|
655
|
-
[{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
|
|
656
|
-
[],
|
|
657
|
-
{},
|
|
658
|
-
undefined,
|
|
659
|
-
undefined,
|
|
660
|
-
undefined,
|
|
661
|
-
[
|
|
662
|
-
{
|
|
663
|
-
id: "alpha",
|
|
664
|
-
name: "Alpha",
|
|
665
|
-
file: "review.alpha.md",
|
|
666
|
-
findings: 2,
|
|
667
|
-
reviewedFiles: 3,
|
|
668
|
-
expectedFiles: 5,
|
|
669
|
-
},
|
|
670
|
-
],
|
|
653
|
+
test("cobertura ignora arquivos declarados fora do escopo", () => {
|
|
654
|
+
const coverage = coverageOf(
|
|
655
|
+
["src/a.ts", "src/b.ts", "src/outro.ts", "src/a.ts"],
|
|
656
|
+
["src/a.ts", "src/b.ts", "src/c.ts"],
|
|
671
657
|
);
|
|
672
|
-
expect(
|
|
673
|
-
expect(md).toContain("review.alpha.md");
|
|
658
|
+
expect(coverage).toEqual({ reviewed: 2, expected: 3 });
|
|
674
659
|
});
|
|
675
660
|
|
|
676
|
-
test("
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
);
|
|
661
|
+
test("lista arquivos sem revisão e sem declaração em skipped", () => {
|
|
662
|
+
expect(
|
|
663
|
+
unaccountedFiles(
|
|
664
|
+
["src/a.ts", "src/fora.ts"],
|
|
665
|
+
["src/c.ts"],
|
|
666
|
+
["src/a.ts", "src/c.ts", "src/b.ts"],
|
|
667
|
+
),
|
|
668
|
+
).toEqual(["src/b.ts"]);
|
|
747
669
|
});
|
|
748
670
|
|
|
749
|
-
test("
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
[],
|
|
759
|
-
|
|
760
|
-
null,
|
|
761
|
-
);
|
|
762
|
-
expect(md).toContain("1/2");
|
|
763
|
-
expect(md).toContain("Não revisados");
|
|
764
|
-
expect(md).toContain("Nenhum achado");
|
|
671
|
+
test("avisa quando um arquivo entra em vários domínios", () => {
|
|
672
|
+
expect(
|
|
673
|
+
overlapMessage({
|
|
674
|
+
watchdogs: { alpha: ["a.ts", "b.ts"], beta: ["b.ts"], gamma: [] },
|
|
675
|
+
}),
|
|
676
|
+
).toEqual([
|
|
677
|
+
"Escopo: 1 arquivo(s) em mais de um domínio (máx. 2) — cada domínio revisa o arquivo com o seu recorte.",
|
|
678
|
+
]);
|
|
679
|
+
expect(
|
|
680
|
+
overlapMessage({ watchdogs: { alpha: ["a.ts"], beta: ["b.ts"] } }),
|
|
681
|
+
).toEqual([]);
|
|
765
682
|
});
|
|
766
683
|
});
|
|
767
684
|
|
|
@@ -803,35 +720,6 @@ describe("context-summary", () => {
|
|
|
803
720
|
expect(summary?.objective?.endsWith("…")).toBe(true);
|
|
804
721
|
expect(summary?.changeSummary).toHaveLength(6);
|
|
805
722
|
});
|
|
806
|
-
|
|
807
|
-
test("renderReport coloca a seção de contexto quando presente", () => {
|
|
808
|
-
const reports: WatchdogReport[] = [
|
|
809
|
-
{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
|
|
810
|
-
];
|
|
811
|
-
const md = renderReport(
|
|
812
|
-
"main",
|
|
813
|
-
reports,
|
|
814
|
-
[],
|
|
815
|
-
{},
|
|
816
|
-
{
|
|
817
|
-
available: true,
|
|
818
|
-
objective: "Entregar X",
|
|
819
|
-
motivation: "Reduzir Y",
|
|
820
|
-
changeSummary: ["Faz A"],
|
|
821
|
-
},
|
|
822
|
-
);
|
|
823
|
-
expect(md).toContain("## Contexto da PR");
|
|
824
|
-
expect(md).toContain("**Objetivo:** Entregar X");
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
test("renderReport omite a seção sem contexto", () => {
|
|
828
|
-
const reports: WatchdogReport[] = [
|
|
829
|
-
{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
|
|
830
|
-
];
|
|
831
|
-
expect(renderReport("main", reports, [], {}, null)).not.toContain(
|
|
832
|
-
"## Contexto da PR",
|
|
833
|
-
);
|
|
834
|
-
});
|
|
835
723
|
});
|
|
836
724
|
|
|
837
725
|
// ---------------------------------------------------------------------------
|
|
@@ -894,7 +782,7 @@ describe("houndsman brief", () => {
|
|
|
894
782
|
expect(brief).toContain("**Objetivo:** Entregar X");
|
|
895
783
|
});
|
|
896
784
|
|
|
897
|
-
test("mostra churn e lista arquivos fora do
|
|
785
|
+
test("mostra churn e lista arquivos fora do recorte", () => {
|
|
898
786
|
const brief = renderBrief(alphaWatchdog, {
|
|
899
787
|
watchdogId: "alpha",
|
|
900
788
|
files: ["src/alpha/keep.ts"],
|
|
@@ -908,7 +796,7 @@ describe("houndsman brief", () => {
|
|
|
908
796
|
deleted: [],
|
|
909
797
|
});
|
|
910
798
|
expect(brief).toContain("+12/−3");
|
|
911
|
-
expect(brief).toContain("Fora do
|
|
799
|
+
expect(brief).toContain("Fora do recorte");
|
|
912
800
|
expect(brief).toContain("src/alpha/skip.ts");
|
|
913
801
|
});
|
|
914
802
|
|