@payconductor/houndsman 0.10.0 → 0.12.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 +4 -19
- package/package.json +1 -1
- package/src/cli/help.ts +0 -7
- package/src/cli/index.ts +9 -3
- package/src/core/ai/ai-client.ts +3 -7
- package/src/core/ai/ai-sdk-model-provider.ts +4 -24
- package/src/core/ai/features/brief-enricher.ts +0 -1
- package/src/core/ai/features/classifier.ts +0 -1
- package/src/core/ai/features/context-summarizer.ts +0 -1
- package/src/core/ai/features/pre-analyzer.ts +0 -1
- package/src/core/ai/features/technical-summarizer.ts +0 -1
- package/src/core/ai/features/verifier.ts +0 -1
- package/src/core/ai/index.ts +0 -3
- package/src/core/ai/prompts/base.ts +7 -0
- package/src/core/ai/prompts/context.ts +2 -1
- package/src/core/ai/prompts/pre-analysis.ts +3 -2
- package/src/core/ai/prompts/technical.ts +2 -1
- package/src/core/ai/schemas.ts +1 -23
- package/src/core/ai/types.ts +2 -15
- package/src/core/ai/usage-budget.ts +1 -1
- package/src/core/ast/diff.ts +37 -2
- package/src/core/ast/service.ts +1 -1
- package/src/core/brief.ts +54 -5
- package/src/core/changed-scope.ts +207 -0
- package/src/core/commands/scaffold.ts +1 -18
- package/src/core/config-loader.ts +18 -4
- package/src/core/config.ts +2 -15
- package/src/core/defaults.ts +0 -6
- package/src/core/engine/review-scope.ts +55 -8
- package/src/core/engine/review-workspace.ts +6 -2
- package/src/core/jev/jev-detector.ts +7 -2
- package/src/core/logging/index.ts +1 -1
- package/src/core/logging/logger.ts +3 -3
- package/src/core/orchestrator.ts +19 -14
- package/src/core/project-config.ts +0 -28
- package/src/core/registry-check.ts +2 -12
- package/src/core/rule-text.ts +3 -55
- package/src/core/services/git-repository.ts +8 -0
- package/src/core/services/worktree-manager.ts +61 -2
- package/src/core/storage/types.ts +0 -1
- package/src/core/types.ts +3 -14
- package/src/index.ts +25 -3
- package/src/server/client/utils/constants.ts +3 -6
- package/src/server/client/utils/types.ts +0 -1
- package/src/skill/OUTPUT-FORMAT.md +5 -0
- package/src/skill/README.md +2 -1
- package/src/skill/SETUP.md +1 -1
- package/src/skill/fix.md +1 -1
- package/src/skill/hunt.md +2 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +50 -32
- package/src/tests/ai-features.test.ts +11 -0
- package/src/tests/changed-scope.test.ts +197 -0
- package/src/tests/fallback.test.ts +1 -96
- package/src/tests/fixtures/fake-model-provider.ts +0 -1
- package/src/tests/review-scope.test.ts +33 -0
- package/src/tests/worktree.test.ts +72 -4
- package/src/cli/commands/hunt-command.ts +0 -51
- package/src/harness/README.md +0 -15
- package/src/harness/agent/agent-context.ts +0 -33
- package/src/harness/agent/agent-errors.ts +0 -30
- package/src/harness/agent/agent-factory.ts +0 -46
- package/src/harness/agent/agent-reporter.ts +0 -34
- package/src/harness/agent/agent-result.ts +0 -16
- package/src/harness/agent/agent-runner.ts +0 -32
- package/src/harness/agent/agent-scheduler.ts +0 -37
- package/src/harness/agent/review-agent.ts +0 -401
- package/src/harness/config/harness-config.ts +0 -89
- package/src/harness/hunt/hunt-harness.ts +0 -83
- package/src/harness/hunt/hunt-options.ts +0 -92
- package/src/harness/hunt/hunt-result.ts +0 -17
- package/src/harness/hunt/hunt-session.ts +0 -359
- package/src/harness/hunt/review-planner.ts +0 -98
- package/src/harness/index.ts +0 -36
- package/src/harness/investigation/agent-state.ts +0 -134
- package/src/harness/investigation/evidence-verifier.ts +0 -129
- package/src/harness/investigation/evidence.ts +0 -110
- package/src/harness/investigation/finding-critic.ts +0 -96
- package/src/harness/investigation/investigation-planner.ts +0 -118
- package/src/harness/investigation/investigation-task.ts +0 -10
- package/src/harness/investigation/review-journal.ts +0 -87
- package/src/harness/investigation/rule-analyzer.ts +0 -178
- package/src/harness/investigation/rule-signals.ts +0 -110
- package/src/harness/prompts/agent-schemas.ts +0 -57
- package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
- package/src/harness/prompts/states/conclude-prompt.ts +0 -16
- package/src/harness/prompts/states/investigate-prompt.ts +0 -37
- package/src/harness/prompts/states/orient-prompt.ts +0 -15
- package/src/harness/prompts/states/verify-prompt.ts +0 -22
- package/src/harness/tools/agent-tool.ts +0 -102
- package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
- package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
- package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
- package/src/harness/tools/builtin/find-references-tool.ts +0 -66
- package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
- package/src/harness/tools/builtin/helpers.ts +0 -115
- package/src/harness/tools/builtin/impact-tool.ts +0 -48
- package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
- package/src/harness/tools/builtin/index.ts +0 -59
- package/src/harness/tools/builtin/list-files-tool.ts +0 -85
- package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
- package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
- package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
- package/src/harness/tools/builtin/search-code-tool.ts +0 -65
- package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
- package/src/harness/tools/path-guard.ts +0 -90
- package/src/harness/tools/result-limiter.ts +0 -49
- package/src/harness/tools/secret-redactor.ts +0 -54
- package/src/harness/tools/tool-cache.ts +0 -50
- package/src/harness/tools/tool-call-budget.ts +0 -60
- package/src/harness/tools/tool-context.ts +0 -5
- package/src/harness/tools/tool-middleware.ts +0 -105
- package/src/harness/tools/tool-policy.ts +0 -51
- package/src/harness/tools/tool-registry.ts +0 -112
- package/src/harness/tools/tool-result.ts +0 -15
- package/src/harness/tools/toolbox.ts +0 -36
- package/src/harness/validation/finding-validator.ts +0 -119
- package/src/tests/fixtures/fake-agent-tools.ts +0 -69
- package/src/tests/fixtures/fake-investigation.ts +0 -64
- package/src/tests/harness-agent.test.ts +0 -141
- package/src/tests/harness-investigation.test.ts +0 -206
- package/src/tests/harness-tools.test.ts +0 -148
- package/src/tests/hunt-command.test.ts +0 -212
- package/src/tests/rule-analyzer.test.ts +0 -61
- package/src/tests/tool-contracts.test.ts +0 -82
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escopo do diff — âncoras, símbolos alterados e fallback nos hunks.
|
|
3
|
+
*
|
|
4
|
+
* Congela a regra que impede o watchdog de reportar código pré-existente: só as
|
|
5
|
+
* faixas tocadas pela revisão (símbolo folha do AST ou hunk) são revisáveis.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { changedAnchors } from "../core/ast/diff";
|
|
10
|
+
import type { AstAnalysis, AstFacts, AstSymbol } from "../core/ast/types";
|
|
11
|
+
import {
|
|
12
|
+
buildChangedScope,
|
|
13
|
+
formatRanges,
|
|
14
|
+
formatSymbols,
|
|
15
|
+
isLineInScope,
|
|
16
|
+
mergeRanges,
|
|
17
|
+
summarizeScope,
|
|
18
|
+
} from "../core/changed-scope";
|
|
19
|
+
import type { DiffSource } from "../core/diff-source";
|
|
20
|
+
|
|
21
|
+
/** Cabecalho padrão de um patch de arquivo. */
|
|
22
|
+
function patch(body: string): string {
|
|
23
|
+
return `diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n${body}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function diffOf(patchText: string): DiffSource {
|
|
27
|
+
return {
|
|
28
|
+
byFile: new Map([["src/a.ts", patchText]]),
|
|
29
|
+
churn: new Map(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function symbol(
|
|
34
|
+
overrides: Partial<AstSymbol> & Pick<AstSymbol, "name" | "kind">,
|
|
35
|
+
): AstSymbol {
|
|
36
|
+
return {
|
|
37
|
+
exported: false,
|
|
38
|
+
startLine: 1,
|
|
39
|
+
endLine: 1,
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function analysis(facts: AstFacts): AstAnalysis {
|
|
45
|
+
return { facts };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FACTS: AstFacts = {
|
|
49
|
+
language: "typescript",
|
|
50
|
+
imports: [],
|
|
51
|
+
exports: [],
|
|
52
|
+
decorators: [],
|
|
53
|
+
hasSyntaxErrors: false,
|
|
54
|
+
symbols: [
|
|
55
|
+
symbol({ name: "Outer", kind: "class", startLine: 1, endLine: 20 }),
|
|
56
|
+
symbol({ name: "Outer.inner", kind: "method", startLine: 5, endLine: 10 }),
|
|
57
|
+
symbol({ name: "top", kind: "function", startLine: 25, endLine: 30 }),
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("changedAnchors", () => {
|
|
62
|
+
test("adições e remoções apontam para o lado novo", () => {
|
|
63
|
+
const anchors = changedAnchors(
|
|
64
|
+
patch(
|
|
65
|
+
"@@ -10,3 +10,4 @@\n const before = 1;\n-const old = 2;\n+const current = 2;\n+const extra = 3;\n const after = 4;\n",
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect([...anchors.added].sort((a, b) => a - b)).toEqual([11, 12]);
|
|
70
|
+
expect([...anchors.deletedAt]).toEqual([11]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("remoção pura ancora no ponto do arquivo (sem consumir linha nova)", () => {
|
|
74
|
+
const anchors = changedAnchors(patch("@@ -10,2 +10,0 @@\n-a\n-b\n"));
|
|
75
|
+
|
|
76
|
+
expect([...anchors.added]).toEqual([]);
|
|
77
|
+
expect([...anchors.deletedAt]).toEqual([10]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("buildChangedScope", () => {
|
|
82
|
+
const entries = [{ path: "src/a.ts", status: "modified" }];
|
|
83
|
+
|
|
84
|
+
test("âncora dentro de símbolo folha vira o intervalo do símbolo", () => {
|
|
85
|
+
const text = patch(
|
|
86
|
+
[
|
|
87
|
+
"@@ -2,0 +3,1 @@",
|
|
88
|
+
"+export const header = 1;",
|
|
89
|
+
"@@ -7,0 +8,1 @@",
|
|
90
|
+
"+ const inside = 1;",
|
|
91
|
+
"@@ -26,0 +27,1 @@",
|
|
92
|
+
"+ return 1;",
|
|
93
|
+
"",
|
|
94
|
+
].join("\n"),
|
|
95
|
+
);
|
|
96
|
+
const scope = buildChangedScope({
|
|
97
|
+
entries,
|
|
98
|
+
diff: diffOf(text),
|
|
99
|
+
ast: new Map([["src/a.ts", analysis(FACTS)]]),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const file = scope["src/a.ts"];
|
|
103
|
+
expect(file?.source).toBe("ast");
|
|
104
|
+
// `inner` (5-10) e `top` (25-30); o container `Outer` não expande.
|
|
105
|
+
expect(file?.symbols.map((item) => item.name)).toEqual(["Outer.inner", "top"]);
|
|
106
|
+
// Cabeçalho da classe (linha 3) fica como faixa avulsa.
|
|
107
|
+
expect(file?.ranges).toEqual([
|
|
108
|
+
{ start: 3, end: 3 },
|
|
109
|
+
{ start: 5, end: 10 },
|
|
110
|
+
{ start: 25, end: 30 },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("sem AST cai para os hunks do diff", () => {
|
|
115
|
+
const text = patch("@@ -10,0 +11,2 @@\n+a\n+b\n");
|
|
116
|
+
const scope = buildChangedScope({ entries, diff: diffOf(text) });
|
|
117
|
+
|
|
118
|
+
const file = scope["src/a.ts"];
|
|
119
|
+
expect(file?.source).toBe("hunks");
|
|
120
|
+
expect(file?.symbols).toEqual([]);
|
|
121
|
+
expect(file?.ranges).toEqual([{ start: 11, end: 12 }]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("rename/binário sem hunks fica sem faixas", () => {
|
|
125
|
+
const scope = buildChangedScope({
|
|
126
|
+
entries: [{ path: "src/a.ts", status: "renamed" }],
|
|
127
|
+
diff: diffOf(patch("")),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(scope["src/a.ts"]).toEqual({ ranges: [], symbols: [], source: "hunks" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("arquivo deletado nem entra no escopo", () => {
|
|
134
|
+
const scope = buildChangedScope({
|
|
135
|
+
entries: [{ path: "src/a.ts", status: "deleted" }],
|
|
136
|
+
diff: diffOf(patch("@@ -1,1 +0,0 @@\n-x\n")),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(scope["src/a.ts"]).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("helpers de escopo", () => {
|
|
144
|
+
test("isLineInScope respeita as faixas", () => {
|
|
145
|
+
const scope = {
|
|
146
|
+
"src/a.ts": {
|
|
147
|
+
ranges: [{ start: 5, end: 10 }],
|
|
148
|
+
symbols: [],
|
|
149
|
+
source: "hunks" as const,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(isLineInScope(scope, "src/a.ts", 7)).toBe(true);
|
|
154
|
+
expect(isLineInScope(scope, "src/a.ts", 11)).toBe(false);
|
|
155
|
+
expect(isLineInScope(scope, "src/b.ts", 7)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("mergeRanges une sobrepostos e adjacentes", () => {
|
|
159
|
+
expect(
|
|
160
|
+
mergeRanges([
|
|
161
|
+
{ start: 10, end: 12 },
|
|
162
|
+
{ start: 11, end: 15 },
|
|
163
|
+
{ start: 16, end: 18 },
|
|
164
|
+
{ start: 30, end: 30 },
|
|
165
|
+
]),
|
|
166
|
+
).toEqual([
|
|
167
|
+
{ start: 10, end: 18 },
|
|
168
|
+
{ start: 30, end: 30 },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("formatadores compactos", () => {
|
|
173
|
+
expect(formatRanges([])).toBe("sem linhas alteradas");
|
|
174
|
+
expect(
|
|
175
|
+
formatRanges([
|
|
176
|
+
{ start: 10, end: 14 },
|
|
177
|
+
{ start: 22, end: 22 },
|
|
178
|
+
]),
|
|
179
|
+
).toBe("10-14, 22");
|
|
180
|
+
expect(
|
|
181
|
+
formatSymbols([
|
|
182
|
+
{ name: "foo", kind: "function", startLine: 10, endLine: 14 },
|
|
183
|
+
]),
|
|
184
|
+
).toBe("`foo` (10-14)");
|
|
185
|
+
expect(
|
|
186
|
+
summarizeScope({
|
|
187
|
+
"src/a.ts": {
|
|
188
|
+
ranges: [{ start: 1, end: 2 }],
|
|
189
|
+
symbols: [
|
|
190
|
+
{ name: "foo", kind: "function", startLine: 1, endLine: 2 },
|
|
191
|
+
],
|
|
192
|
+
source: "ast",
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
).toEqual({ files: 1, ranges: 1, symbols: 1 });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fallback de classificação e findings emergentes (lógica/domínio).
|
|
3
|
-
*/
|
|
1
|
+
/** Fallback de classificação (arquivos sem dono). */
|
|
4
2
|
|
|
5
3
|
import { describe, expect, test } from "bun:test";
|
|
6
4
|
import { defineHoundsmanConfig } from "../core/config";
|
|
7
5
|
import { classifyEntries } from "../core/file-classifier";
|
|
8
6
|
import { checkRegistry } from "../core/registry-check";
|
|
9
7
|
import { defineWatchdog } from "../core/types";
|
|
10
|
-
import { AgentState } from "../harness/investigation/agent-state";
|
|
11
|
-
import { EvidenceVerifier } from "../harness/investigation/evidence-verifier";
|
|
12
|
-
import { FindingCritic } from "../harness/investigation/finding-critic";
|
|
13
|
-
import { FindingValidator } from "../harness/validation/finding-validator";
|
|
14
8
|
|
|
15
9
|
const alpha = defineWatchdog({
|
|
16
10
|
id: "alpha",
|
|
@@ -28,7 +22,6 @@ const fallback = defineWatchdog({
|
|
|
28
22
|
description: "Fallback de lógica/domínio.",
|
|
29
23
|
scope: "arquivos sem dono",
|
|
30
24
|
fallback: true,
|
|
31
|
-
allowEmergentFindings: true,
|
|
32
25
|
rules: [
|
|
33
26
|
{
|
|
34
27
|
severity: "attention",
|
|
@@ -82,91 +75,3 @@ describe("classificação com fallback", () => {
|
|
|
82
75
|
).toBe(true);
|
|
83
76
|
});
|
|
84
77
|
});
|
|
85
|
-
|
|
86
|
-
describe("findings emergentes", () => {
|
|
87
|
-
const file = "a.ts";
|
|
88
|
-
const content = "const x = 1;\n";
|
|
89
|
-
const state = new AgentState();
|
|
90
|
-
const evidence = state.addEvidence({
|
|
91
|
-
file,
|
|
92
|
-
startLine: 1,
|
|
93
|
-
endLine: 1,
|
|
94
|
-
snippet: content.trim(),
|
|
95
|
-
kind: "definition",
|
|
96
|
-
sourceTool: "test",
|
|
97
|
-
confidence: 0.9,
|
|
98
|
-
});
|
|
99
|
-
const draft = {
|
|
100
|
-
rule: "Lógica: a função fazA na verdade fazB",
|
|
101
|
-
severity: "blocking" as const,
|
|
102
|
-
file,
|
|
103
|
-
line: 1,
|
|
104
|
-
message: "comportamento diverge do nome",
|
|
105
|
-
action: "renomear ou corrigir",
|
|
106
|
-
evidenceIds: [evidence.id],
|
|
107
|
-
confidence: 0.9,
|
|
108
|
-
};
|
|
109
|
-
const verifier = new EvidenceVerifier({
|
|
110
|
-
allowedFiles: [file],
|
|
111
|
-
readFile: (path) => (path === file ? content : null),
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("crítico aceita e limita a severidade quando habilitado", () => {
|
|
115
|
-
const critic = new FindingCritic({
|
|
116
|
-
knownRules: new Set([fallback.rules[0]?.text ?? ""]),
|
|
117
|
-
verifier,
|
|
118
|
-
allowEmergentFindings: true,
|
|
119
|
-
});
|
|
120
|
-
const decision = critic.reviewOne(draft, state);
|
|
121
|
-
expect(decision.verdict).toBe("keep");
|
|
122
|
-
expect(decision.draft.severity).toBe("attention");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("crítico descarta quando o opt-in está desligado", () => {
|
|
126
|
-
const critic = new FindingCritic({
|
|
127
|
-
knownRules: new Set([fallback.rules[0]?.text ?? ""]),
|
|
128
|
-
verifier,
|
|
129
|
-
});
|
|
130
|
-
const decision = critic.reviewOne(draft, state);
|
|
131
|
-
expect(decision.verdict).toBe("drop");
|
|
132
|
-
expect(decision.reason).toContain("desabilitado");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("validador espelha a mesma regra", () => {
|
|
136
|
-
const report = {
|
|
137
|
-
watchdogId: "default",
|
|
138
|
-
reviewedFiles: [file],
|
|
139
|
-
skipped: [],
|
|
140
|
-
findings: [draft],
|
|
141
|
-
};
|
|
142
|
-
const allowed = new FindingValidator({
|
|
143
|
-
watchdogId: "default",
|
|
144
|
-
knownRules: new Set([fallback.rules[0]?.text ?? ""]),
|
|
145
|
-
allowedFiles: new Set([file]),
|
|
146
|
-
overflow: new Set(),
|
|
147
|
-
readFile: (path) => (path === file ? content : null),
|
|
148
|
-
allowEmergentFindings: true,
|
|
149
|
-
}).validate(report);
|
|
150
|
-
expect(allowed.report.findings[0]?.severity).toBe("attention");
|
|
151
|
-
expect(allowed.dropped).toHaveLength(0);
|
|
152
|
-
|
|
153
|
-
const denied = new FindingValidator({
|
|
154
|
-
watchdogId: "default",
|
|
155
|
-
knownRules: new Set([fallback.rules[0]?.text ?? ""]),
|
|
156
|
-
allowedFiles: new Set([file]),
|
|
157
|
-
overflow: new Set(),
|
|
158
|
-
readFile: (path) => (path === file ? content : null),
|
|
159
|
-
}).validate(report);
|
|
160
|
-
expect(denied.report.findings).toHaveLength(0);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("finding emergente sem descrição é descartado", () => {
|
|
164
|
-
const critic = new FindingCritic({
|
|
165
|
-
knownRules: new Set([fallback.rules[0]?.text ?? ""]),
|
|
166
|
-
verifier,
|
|
167
|
-
allowEmergentFindings: true,
|
|
168
|
-
});
|
|
169
|
-
const decision = critic.reviewOne({ ...draft, rule: "Lógica:" }, state);
|
|
170
|
-
expect(decision.verdict).toBe("drop");
|
|
171
|
-
});
|
|
172
|
-
});
|
|
@@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { AstService } from "../core/ast/service";
|
|
12
13
|
import { ReviewScopeResolver, type ScopeOptions } from "../core/engine/review-scope";
|
|
13
14
|
import type { FindingState, FindingsStateFile } from "../core/findings-state";
|
|
14
15
|
import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
|
|
@@ -138,6 +139,35 @@ describe("ReviewScopeResolver", () => {
|
|
|
138
139
|
if (scope.kind !== "review") throw new Error("esperava review");
|
|
139
140
|
expect(scope.classification.mode).toBe("full");
|
|
140
141
|
expect(scope.classification.headSha).toBe(repo.head);
|
|
142
|
+
expect(scope.classification.changedScope?.["src/alpha/a.ts"]).toEqual({
|
|
143
|
+
ranges: [{ start: 1, end: 1 }],
|
|
144
|
+
symbols: [],
|
|
145
|
+
source: "hunks",
|
|
146
|
+
});
|
|
147
|
+
} finally {
|
|
148
|
+
process.chdir(originalCwd);
|
|
149
|
+
repo.cleanup();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("escopo usa o símbolo AST quando disponível", async () => {
|
|
154
|
+
const repo = makeRepo();
|
|
155
|
+
const originalCwd = process.cwd();
|
|
156
|
+
try {
|
|
157
|
+
process.chdir(repo.cwd);
|
|
158
|
+
const ast = new AstService(fakeHoundsmanConfig);
|
|
159
|
+
const scope = await new ReviewScopeResolver(
|
|
160
|
+
options({ base: repo.base, full: true }),
|
|
161
|
+
fakeHoundsmanConfig,
|
|
162
|
+
ast,
|
|
163
|
+
).resolve(null, repo.head);
|
|
164
|
+
|
|
165
|
+
expect(scope.kind).toBe("review");
|
|
166
|
+
if (scope.kind !== "review") throw new Error("esperava review");
|
|
167
|
+
const file = scope.classification.changedScope?.["src/alpha/a.ts"];
|
|
168
|
+
expect(file?.source).toBe("ast");
|
|
169
|
+
expect(file?.symbols.map((symbol) => symbol.name)).toEqual(["a"]);
|
|
170
|
+
expect(file?.ranges).toEqual([{ start: 1, end: 1 }]);
|
|
141
171
|
} finally {
|
|
142
172
|
process.chdir(originalCwd);
|
|
143
173
|
repo.cleanup();
|
|
@@ -163,6 +193,9 @@ describe("ReviewScopeResolver", () => {
|
|
|
163
193
|
expect(scope.classification.mode).toBe("incremental");
|
|
164
194
|
expect(scope.classification.reviewedFrom).toBe(repo.base);
|
|
165
195
|
expect(Object.keys(scope.classification.files)).toEqual(["src/alpha/a.ts"]);
|
|
196
|
+
expect(scope.classification.changedScope?.["src/alpha/a.ts"]?.ranges).toEqual(
|
|
197
|
+
[{ start: 1, end: 1 }],
|
|
198
|
+
);
|
|
166
199
|
expect(scope.classification.carryover?.map((f) => f.file)).toEqual([
|
|
167
200
|
"src/alpha/keep.ts",
|
|
168
201
|
]);
|
|
@@ -24,9 +24,13 @@ import { createGitRunner } from "../core/git-runner";
|
|
|
24
24
|
import { defaultOptions } from "../core/orchestrator";
|
|
25
25
|
import type { GhRunner } from "../core/services/gh-client";
|
|
26
26
|
import { GitRepository } from "../core/services/git-repository";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
parseWorktreeList,
|
|
29
|
+
syncProjectLayer,
|
|
30
|
+
syncProjectLayerForRoot,
|
|
31
|
+
WorktreeManager,
|
|
32
|
+
} from "../core/services/worktree-manager";
|
|
28
33
|
import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
|
|
29
|
-
import { parseHuntArgs } from "../harness/hunt/hunt-options";
|
|
30
34
|
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
31
35
|
|
|
32
36
|
function git(cwd: string, args: string[]): string {
|
|
@@ -122,11 +126,10 @@ function fakeGh(
|
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
describe("flags de worktree", () => {
|
|
125
|
-
test("parse de review
|
|
129
|
+
test("parse de review e exclusão mútua", () => {
|
|
126
130
|
expect(parseReviewArgs(["--pr", "7"]).pr).toBe(7);
|
|
127
131
|
expect(parseReviewArgs(["--worktree", "/tmp/wt"]).worktree).toBe("/tmp/wt");
|
|
128
132
|
expect(() => parseReviewArgs(["--pr", "7", "--worktree", "/tmp/wt"])).toThrow();
|
|
129
|
-
expect(parseHuntArgs(["--pr", "8"]).pr).toBe(8);
|
|
130
133
|
});
|
|
131
134
|
});
|
|
132
135
|
|
|
@@ -186,6 +189,71 @@ describe("nomes de diretório do worktree", () => {
|
|
|
186
189
|
});
|
|
187
190
|
});
|
|
188
191
|
|
|
192
|
+
describe("camada gerada no worktree", () => {
|
|
193
|
+
test("espelha `.houndsman/core` do clone principal e ignora origem ausente", () => {
|
|
194
|
+
const sandbox = makeSandbox();
|
|
195
|
+
try {
|
|
196
|
+
const worktree = join(sandbox.root, "wt-layer");
|
|
197
|
+
mkdirSync(worktree, { recursive: true });
|
|
198
|
+
mkdirSync(join(sandbox.repo, ".houndsman", "core"), { recursive: true });
|
|
199
|
+
writeFileSync(
|
|
200
|
+
join(sandbox.repo, ".houndsman", "core", "types.ts"),
|
|
201
|
+
"export {};\n",
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(syncProjectLayer(sandbox.repo, worktree)).toBe(true);
|
|
205
|
+
expect(existsSync(join(worktree, ".houndsman", "core", "types.ts"))).toBe(
|
|
206
|
+
true,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Sem camada na origem: no-op (fail-open).
|
|
210
|
+
const empty = join(sandbox.root, "empty");
|
|
211
|
+
mkdirSync(empty, { recursive: true });
|
|
212
|
+
expect(syncProjectLayer(empty, worktree)).toBe(false);
|
|
213
|
+
} finally {
|
|
214
|
+
sandbox.cleanup();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("ensure copia a camada e o auto-reparo repõe num worktree existente", () => {
|
|
219
|
+
const sandbox = makeSandbox();
|
|
220
|
+
try {
|
|
221
|
+
mkdirSync(join(sandbox.repo, ".houndsman", "core"), { recursive: true });
|
|
222
|
+
writeFileSync(
|
|
223
|
+
join(sandbox.repo, ".houndsman", "core", "types.ts"),
|
|
224
|
+
"export {};\n",
|
|
225
|
+
);
|
|
226
|
+
const manager = new WorktreeManager(
|
|
227
|
+
new GitRepository(createGitRunner(sandbox.repo)),
|
|
228
|
+
{
|
|
229
|
+
repoKey: "path:fake",
|
|
230
|
+
repoRoot: sandbox.repo,
|
|
231
|
+
baseDir: sandbox.home,
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const ensured = manager.ensure({
|
|
236
|
+
branch: "feature",
|
|
237
|
+
ref: sandbox.featureSha,
|
|
238
|
+
});
|
|
239
|
+
const layer = join(ensured.path, ".houndsman", "core", "types.ts");
|
|
240
|
+
expect(existsSync(layer)).toBe(true);
|
|
241
|
+
|
|
242
|
+
// Comando rodando no worktree (sem `--pr`) também se auto-repara.
|
|
243
|
+
rmSync(join(ensured.path, ".houndsman", "core"), {
|
|
244
|
+
recursive: true,
|
|
245
|
+
force: true,
|
|
246
|
+
});
|
|
247
|
+
expect(syncProjectLayerForRoot(ensured.path)).toBe(true);
|
|
248
|
+
expect(existsSync(layer)).toBe(true);
|
|
249
|
+
|
|
250
|
+
manager.remove("feature");
|
|
251
|
+
} finally {
|
|
252
|
+
sandbox.cleanup();
|
|
253
|
+
}
|
|
254
|
+
}, 20_000);
|
|
255
|
+
});
|
|
256
|
+
|
|
189
257
|
describe("WorktreeManager", () => {
|
|
190
258
|
test("cria detached, reusa o mesmo commit e remove", () => {
|
|
191
259
|
const sandbox = makeSandbox();
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/** Comando `hunt` — revisão nativa com IA (harness próprio). */
|
|
2
|
-
|
|
3
|
-
import { resolveReviewWorkspace } from "../../core/engine/review-workspace";
|
|
4
|
-
import { HuntHarness } from "../../harness";
|
|
5
|
-
import { parseHuntArgs } from "../../harness/hunt/hunt-options";
|
|
6
|
-
import type { CliContext, Command } from "./command";
|
|
7
|
-
|
|
8
|
-
/** Executa o harness nativo. */
|
|
9
|
-
export class HuntCommand implements Command {
|
|
10
|
-
readonly name = "hunt";
|
|
11
|
-
|
|
12
|
-
async run(args: string[], context: CliContext): Promise<number> {
|
|
13
|
-
const options = parseHuntArgs(args);
|
|
14
|
-
const config = await context.config();
|
|
15
|
-
const workspace = resolveReviewWorkspace(
|
|
16
|
-
{
|
|
17
|
-
cwd: context.cwd,
|
|
18
|
-
base: options.base,
|
|
19
|
-
head: options.head,
|
|
20
|
-
branch: options.branch,
|
|
21
|
-
pr: options.pr,
|
|
22
|
-
worktree: options.worktree,
|
|
23
|
-
},
|
|
24
|
-
{ worktreesLocation: config.worktrees?.location },
|
|
25
|
-
);
|
|
26
|
-
for (const message of workspace.messages) context.logger.info(message);
|
|
27
|
-
|
|
28
|
-
const engine = await context.engine({
|
|
29
|
-
root: workspace.root,
|
|
30
|
-
mainRoot: workspace.mainRoot,
|
|
31
|
-
});
|
|
32
|
-
const resolved = {
|
|
33
|
-
...options,
|
|
34
|
-
branch: workspace.branch,
|
|
35
|
-
base: workspace.base,
|
|
36
|
-
head: workspace.head,
|
|
37
|
-
pr: workspace.prNumber,
|
|
38
|
-
worktreePath: workspace.worktreePath,
|
|
39
|
-
};
|
|
40
|
-
try {
|
|
41
|
-
const harness = await HuntHarness.from(engine, resolved, context.logger);
|
|
42
|
-
const result = await harness.run();
|
|
43
|
-
for (const message of result.messages) context.logger.info(message);
|
|
44
|
-
if (result.status === "failed") return 1;
|
|
45
|
-
if (options.failOnBlocking && result.blockers > 0) return 1;
|
|
46
|
-
return 0;
|
|
47
|
-
} finally {
|
|
48
|
-
engine.close();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/harness/README.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# `src/harness` — harness nativo do houndsman
|
|
2
|
-
|
|
3
|
-
Este diretório é o **runtime de agentes do próprio houndsman** (`houndsman hunt`):
|
|
4
|
-
tools read-only, investigação, máquina de estados e o agente revisor.
|
|
5
|
-
|
|
6
|
-
> O **harness externo** (skill `/houndsman` para ClaudeCode/Copilot/agents) continua em
|
|
7
|
-
> `src/skill/`. Os dois coexistem; o nativo é opt-in.
|
|
8
|
-
|
|
9
|
-
Regras de dependência:
|
|
10
|
-
|
|
11
|
-
- `harness/` pode importar `core/` (inclusive `core/ai`).
|
|
12
|
-
- `core/` **nunca** importa `harness/`.
|
|
13
|
-
- `cli/` importa ambos.
|
|
14
|
-
|
|
15
|
-
Documentação detalhada: [`docs/HARNESS.md`](../../docs/HARNESS.md).
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contexto de um agente de revisão (um watchdog).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { AstAnalysis, AstImpact } from "../../core/ast/types";
|
|
6
|
-
import type { DiffSource } from "../../core/diff-source";
|
|
7
|
-
import type { Watchdog } from "../../core/domain/watchdog";
|
|
8
|
-
import type { WatchdogRenderContext } from "../../core/types";
|
|
9
|
-
import type { RuleAnalysis } from "../investigation/rule-analyzer";
|
|
10
|
-
|
|
11
|
-
/** Dados imutáveis entregues ao agente. */
|
|
12
|
-
export type AgentContext = {
|
|
13
|
-
watchdog: Watchdog;
|
|
14
|
-
projectName: string;
|
|
15
|
-
branch: string;
|
|
16
|
-
/** Arquivos do domínio (dentro do orçamento). */
|
|
17
|
-
files: readonly string[];
|
|
18
|
-
/** Arquivos do domínio fora do orçamento (não ler; registrar em skipped). */
|
|
19
|
-
overflow: readonly string[];
|
|
20
|
-
deleted: readonly string[];
|
|
21
|
-
docs: readonly string[];
|
|
22
|
-
context?: WatchdogRenderContext | null;
|
|
23
|
-
ast: ReadonlyMap<string, AstAnalysis>;
|
|
24
|
-
impact?: AstImpact;
|
|
25
|
-
/** Caminho de saída do findings (staging). */
|
|
26
|
-
outputPath: string;
|
|
27
|
-
maxSteps: number;
|
|
28
|
-
maxCalls: number;
|
|
29
|
-
repoRoot: string;
|
|
30
|
-
diff: DiffSource;
|
|
31
|
-
/** Análises determinísticas das regras do watchdog. */
|
|
32
|
-
rules: readonly RuleAnalysis[];
|
|
33
|
-
};
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Erros do harness nativo.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/** Falha de configuração/execução do agente. */
|
|
6
|
-
export class AgentError extends Error {
|
|
7
|
-
constructor(
|
|
8
|
-
message: string,
|
|
9
|
-
readonly watchdogId?: string,
|
|
10
|
-
) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.name = "AgentError";
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Orçamento de IA/tools esgotado durante a investigação. */
|
|
17
|
-
export class AgentBudgetError extends AgentError {
|
|
18
|
-
constructor(message: string, watchdogId?: string) {
|
|
19
|
-
super(message, watchdogId);
|
|
20
|
-
this.name = "AgentBudgetError";
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Saída do modelo inválida após as tentativas permitidas. */
|
|
25
|
-
export class AgentOutputError extends AgentError {
|
|
26
|
-
constructor(message: string, watchdogId?: string) {
|
|
27
|
-
super(message, watchdogId);
|
|
28
|
-
this.name = "AgentOutputError";
|
|
29
|
-
}
|
|
30
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Factory de agentes — injeta provider, tools e limites resolvidos.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { ModelProvider } from "../../core/ai";
|
|
6
|
-
import type { AstService } from "../../core/ast/service";
|
|
7
|
-
import type { HarnessConfig } from "../config/harness-config";
|
|
8
|
-
import type { ToolPolicy } from "../tools/tool-policy";
|
|
9
|
-
import type { ToolRegistry } from "../tools/tool-registry";
|
|
10
|
-
import type { AgentContext } from "./agent-context";
|
|
11
|
-
import type { AgentReporter } from "./agent-reporter";
|
|
12
|
-
import { ReviewAgent } from "./review-agent";
|
|
13
|
-
|
|
14
|
-
/** Dependências compartilhadas por todos os agentes. */
|
|
15
|
-
export type AgentFactoryDeps = {
|
|
16
|
-
provider: ModelProvider;
|
|
17
|
-
registry: ToolRegistry;
|
|
18
|
-
policy?: ToolPolicy;
|
|
19
|
-
reporter: AgentReporter;
|
|
20
|
-
config: HarnessConfig;
|
|
21
|
-
ast: AstService;
|
|
22
|
-
readFile?: (path: string) => string | null;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/** Cria `ReviewAgent`s a partir do contexto de cada watchdog. */
|
|
26
|
-
export class AgentFactory {
|
|
27
|
-
constructor(private readonly deps: AgentFactoryDeps) {}
|
|
28
|
-
|
|
29
|
-
/** Instancia um agente para o contexto. */
|
|
30
|
-
create(context: AgentContext): ReviewAgent {
|
|
31
|
-
const { config } = this.deps;
|
|
32
|
-
return new ReviewAgent({
|
|
33
|
-
context,
|
|
34
|
-
provider: this.deps.provider,
|
|
35
|
-
registry: this.deps.registry,
|
|
36
|
-
policy: this.deps.policy,
|
|
37
|
-
reporter: this.deps.reporter,
|
|
38
|
-
maxOutputTokens: config.maxOutputTokensFor(context.watchdog.id),
|
|
39
|
-
temperature: config.temperature,
|
|
40
|
-
providerOptions: config.providerOptions,
|
|
41
|
-
timeoutMs: config.timeoutMs,
|
|
42
|
-
ast: this.deps.ast,
|
|
43
|
-
readFile: this.deps.readFile,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reporter de progresso de um agente.
|
|
3
|
-
*
|
|
4
|
-
* O journal é sempre coletado (`harness-transcript.json`); o console é opcional.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { LoggerLike } from "../../core/logging";
|
|
8
|
-
import { ReviewJournal } from "../investigation/review-journal";
|
|
9
|
-
|
|
10
|
-
/** Interface de progresso. */
|
|
11
|
-
export interface AgentReporter {
|
|
12
|
-
readonly journal: ReviewJournal;
|
|
13
|
-
progress(message: string): void;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Reporter silencioso (testes). */
|
|
17
|
-
export class SilentReporter implements AgentReporter {
|
|
18
|
-
readonly journal = new ReviewJournal();
|
|
19
|
-
|
|
20
|
-
progress(): void {
|
|
21
|
-
// intencionalmente silencioso
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Reporter apoiado no logger único do CLI. */
|
|
26
|
-
export class LoggerReporter implements AgentReporter {
|
|
27
|
-
readonly journal = new ReviewJournal();
|
|
28
|
-
|
|
29
|
-
constructor(private readonly logger: LoggerLike) {}
|
|
30
|
-
|
|
31
|
-
progress(message: string): void {
|
|
32
|
-
this.logger.progress(message);
|
|
33
|
-
}
|
|
34
|
-
}
|