@payconductor/houndsman 0.5.0 → 0.7.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 +24 -4
- package/assets/grammars/tree-sitter-javascript.wasm +0 -0
- package/assets/grammars/tree-sitter-tsx.wasm +0 -0
- package/assets/grammars/tree-sitter-typescript.wasm +0 -0
- package/assets/grammars/web-tree-sitter.wasm +0 -0
- package/package.json +11 -3
- package/src/cli/commands/command.ts +5 -0
- package/src/cli/commands/doctor-command.ts +116 -24
- package/src/cli/commands/hunt-command.ts +25 -0
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +10 -6
- package/src/core/ai/ai-client.ts +221 -0
- package/src/core/ai/ai-sdk-model-provider.ts +101 -0
- package/src/core/ai/errors.ts +36 -0
- package/src/core/ai/features/brief-enricher.ts +62 -0
- package/src/core/ai/features/classifier.ts +50 -0
- package/src/core/ai/features/context-summarizer.ts +31 -0
- package/src/core/ai/features/pre-analyzer.ts +51 -0
- package/src/core/ai/features/verifier.ts +45 -0
- package/src/core/ai/index.ts +56 -0
- package/src/core/ai/model-factory.ts +137 -0
- package/src/core/ai/prompts/base.ts +36 -0
- package/src/core/ai/prompts/brief.ts +87 -0
- package/src/core/ai/prompts/classification.ts +36 -0
- package/src/core/ai/prompts/context.ts +29 -0
- package/src/core/ai/prompts/pre-analysis.ts +49 -0
- package/src/core/ai/prompts/verify.ts +40 -0
- package/src/core/ai/schemas.ts +110 -0
- package/src/core/ai/types.ts +76 -0
- package/src/core/ai/usage-budget.ts +75 -0
- package/src/core/ai/usage-recorder.ts +79 -0
- package/src/core/assets.ts +16 -0
- package/src/core/ast/diff.ts +220 -0
- package/src/core/ast/impact.ts +127 -0
- package/src/core/ast/parser.ts +167 -0
- package/src/core/ast/service.ts +233 -0
- package/src/core/ast/symbols.ts +294 -0
- package/src/core/ast/types.ts +125 -0
- package/src/core/brief.ts +138 -0
- package/src/core/commands/findings.ts +65 -2
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +159 -10
- package/src/core/commands/update.ts +5 -3
- package/src/core/config.ts +85 -0
- package/src/core/defaults.ts +27 -0
- package/src/core/domain/watchdog.ts +43 -5
- package/src/core/engine/houndsman-engine.ts +16 -1
- package/src/core/engine/review-scope.ts +52 -6
- package/src/core/engine/review-session.ts +27 -2
- package/src/core/env.ts +101 -0
- package/src/core/file-classifier.ts +25 -4
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/jev/jev-client.ts +8 -7
- package/src/core/jev/jev-verify.ts +2 -1
- package/src/core/logging/index.ts +17 -0
- package/src/core/logging/logger.ts +187 -0
- package/src/core/orchestrator.ts +357 -9
- package/src/core/project-config.ts +123 -1
- package/src/core/registry-check.ts +148 -6
- package/src/core/renderers/manifest-writer.ts +33 -0
- package/src/core/rule-text.ts +97 -0
- package/src/core/services/file-classifier.ts +10 -3
- package/src/core/services/gh-client.ts +38 -22
- package/src/core/services/verifier.ts +14 -3
- package/src/core/storage/paths.ts +6 -5
- package/src/core/storage/types.ts +6 -0
- package/src/core/storage/work-area.ts +12 -0
- package/src/core/types.ts +37 -1
- package/src/core/verify.ts +17 -5
- package/src/harness/README.md +15 -0
- package/src/harness/agent/agent-context.ts +33 -0
- package/src/harness/agent/agent-errors.ts +30 -0
- package/src/harness/agent/agent-factory.ts +45 -0
- package/src/harness/agent/agent-reporter.ts +34 -0
- package/src/harness/agent/agent-result.ts +16 -0
- package/src/harness/agent/agent-runner.ts +32 -0
- package/src/harness/agent/agent-scheduler.ts +37 -0
- package/src/harness/agent/review-agent.ts +391 -0
- package/src/harness/config/harness-config.ts +84 -0
- package/src/harness/hunt/hunt-harness.ts +83 -0
- package/src/harness/hunt/hunt-options.ts +75 -0
- package/src/harness/hunt/hunt-result.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +372 -0
- package/src/harness/hunt/review-planner.ts +98 -0
- package/src/harness/index.ts +36 -0
- package/src/harness/investigation/agent-state.ts +134 -0
- package/src/harness/investigation/evidence-verifier.ts +129 -0
- package/src/harness/investigation/evidence.ts +110 -0
- package/src/harness/investigation/finding-critic.ts +96 -0
- package/src/harness/investigation/investigation-planner.ts +118 -0
- package/src/harness/investigation/investigation-task.ts +10 -0
- package/src/harness/investigation/review-journal.ts +87 -0
- package/src/harness/investigation/rule-analyzer.ts +178 -0
- package/src/harness/investigation/rule-signals.ts +110 -0
- package/src/harness/prompts/agent-schemas.ts +57 -0
- package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
- package/src/harness/prompts/states/conclude-prompt.ts +16 -0
- package/src/harness/prompts/states/investigate-prompt.ts +37 -0
- package/src/harness/prompts/states/orient-prompt.ts +15 -0
- package/src/harness/prompts/states/verify-prompt.ts +22 -0
- package/src/harness/tools/agent-tool.ts +102 -0
- package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
- package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
- package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
- package/src/harness/tools/builtin/find-references-tool.ts +66 -0
- package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
- package/src/harness/tools/builtin/helpers.ts +115 -0
- package/src/harness/tools/builtin/impact-tool.ts +48 -0
- package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
- package/src/harness/tools/builtin/index.ts +59 -0
- package/src/harness/tools/builtin/list-files-tool.ts +85 -0
- package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
- package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
- package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
- package/src/harness/tools/builtin/search-code-tool.ts +65 -0
- package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
- package/src/harness/tools/path-guard.ts +90 -0
- package/src/harness/tools/result-limiter.ts +49 -0
- package/src/harness/tools/secret-redactor.ts +54 -0
- package/src/harness/tools/tool-cache.ts +50 -0
- package/src/harness/tools/tool-call-budget.ts +60 -0
- package/src/harness/tools/tool-context.ts +5 -0
- package/src/harness/tools/tool-middleware.ts +105 -0
- package/src/harness/tools/tool-policy.ts +51 -0
- package/src/harness/tools/tool-registry.ts +112 -0
- package/src/harness/tools/tool-result.ts +15 -0
- package/src/harness/tools/toolbox.ts +36 -0
- package/src/harness/validation/finding-validator.ts +119 -0
- package/src/index.ts +46 -0
- package/src/server/client/App.tsx +38 -12
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/Sidebar.tsx +397 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +19 -0
- package/src/server/client/index.tsx +1 -1
- package/src/server/client/utils/constants.ts +29 -5
- package/src/server/client/utils/format.ts +47 -2
- package/src/server/client/utils/types.ts +88 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +115 -5
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +202 -0
- package/src/server/run-repository.ts +33 -1
- package/src/skill/OUTPUT-FORMAT.md +12 -0
- package/src/skill/SETUP.md +6 -2
- package/src/skill/hunt.md +14 -2
- package/src/skill/verify.md +1 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
- package/src/tests/ai-core.test.ts +120 -0
- package/src/tests/ai-features.test.ts +183 -0
- package/src/tests/ast.test.ts +335 -0
- package/src/tests/cli.test.ts +33 -0
- package/src/tests/env.test.ts +69 -0
- package/src/tests/fallback.test.ts +172 -0
- package/src/tests/fixtures/fake-agent-tools.ts +69 -0
- package/src/tests/fixtures/fake-investigation.ts +64 -0
- package/src/tests/fixtures/fake-model-provider.ts +48 -0
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/harness-agent.test.ts +122 -0
- package/src/tests/harness-investigation.test.ts +206 -0
- package/src/tests/harness-tools.test.ts +148 -0
- package/src/tests/hunt-command.test.ts +212 -0
- package/src/tests/logger.test.ts +83 -0
- package/src/tests/review-scope.test.ts +11 -11
- package/src/tests/rule-analyzer.test.ts +61 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +295 -2
- package/src/tests/tool-contracts.test.ts +82 -0
- package/src/tests/verify.test.ts +1 -1
- package/src/server/client/RunList.tsx +0 -75
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures de investigação/agente (sem rede, sem git).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { AstService } from "../../core/ast/service";
|
|
6
|
+
import { Watchdog } from "../../core/domain/watchdog";
|
|
7
|
+
import type { AgentContext } from "../../harness/agent/agent-context";
|
|
8
|
+
import { SilentReporter } from "../../harness/agent/agent-reporter";
|
|
9
|
+
import type { ReviewAgentDeps } from "../../harness/agent/review-agent";
|
|
10
|
+
import { RuleAnalyzer } from "../../harness/investigation/rule-analyzer";
|
|
11
|
+
import { BuiltinToolsModule } from "../../harness/tools/builtin";
|
|
12
|
+
import { ToolRegistry } from "../../harness/tools/tool-registry";
|
|
13
|
+
import type { FakeModelProvider } from "./fake-model-provider";
|
|
14
|
+
import { fakeHoundsmanConfig } from "./fake-project";
|
|
15
|
+
|
|
16
|
+
/** Contexto de agente para o watchdog `alpha`. */
|
|
17
|
+
export function fakeAgentContext(overrides: Partial<AgentContext> = {}): AgentContext {
|
|
18
|
+
const watchdog = new Watchdog(fakeHoundsmanConfig.watchdogs.alpha);
|
|
19
|
+
const rules = new RuleAnalyzer().analyze(watchdog.rules);
|
|
20
|
+
return {
|
|
21
|
+
watchdog,
|
|
22
|
+
projectName: "Fake",
|
|
23
|
+
branch: "main",
|
|
24
|
+
files: ["src/alpha/a.ts"],
|
|
25
|
+
overflow: [],
|
|
26
|
+
deleted: [],
|
|
27
|
+
docs: [],
|
|
28
|
+
context: null,
|
|
29
|
+
ast: new Map(),
|
|
30
|
+
outputPath: "findings/alpha.json",
|
|
31
|
+
maxSteps: 4,
|
|
32
|
+
maxCalls: 8,
|
|
33
|
+
repoRoot: process.cwd(),
|
|
34
|
+
diff: { byFile: new Map(), churn: new Map() },
|
|
35
|
+
rules,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Registry com tools built-in. */
|
|
41
|
+
export function builtinRegistry(): ToolRegistry {
|
|
42
|
+
const registry = new ToolRegistry();
|
|
43
|
+
registry.registerModule(new BuiltinToolsModule());
|
|
44
|
+
return registry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deps completas de um `ReviewAgent` com provider fake. */
|
|
48
|
+
export function fakeAgentDeps(
|
|
49
|
+
provider: FakeModelProvider,
|
|
50
|
+
overrides: Partial<ReviewAgentDeps> = {},
|
|
51
|
+
): ReviewAgentDeps {
|
|
52
|
+
const context = overrides.context ?? fakeAgentContext();
|
|
53
|
+
return {
|
|
54
|
+
context,
|
|
55
|
+
provider,
|
|
56
|
+
registry: builtinRegistry(),
|
|
57
|
+
reporter: new SilentReporter(),
|
|
58
|
+
maxOutputTokens: 512,
|
|
59
|
+
temperature: 0,
|
|
60
|
+
timeoutMs: 5_000,
|
|
61
|
+
ast: new AstService(fakeHoundsmanConfig),
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider de modelo fake — suíte sem rede.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AiUsage, ModelProvider, ModelRequest, ModelResponse } from "../../core/ai";
|
|
6
|
+
|
|
7
|
+
/** Uso padrão de uma chamada fake. */
|
|
8
|
+
export function fakeUsage(calls = 1): AiUsage {
|
|
9
|
+
return {
|
|
10
|
+
inputTokens: 10 * calls,
|
|
11
|
+
outputTokens: 5 * calls,
|
|
12
|
+
totalTokens: 15 * calls,
|
|
13
|
+
calls,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Handler roteirizado: recebe o request e o índice da chamada. */
|
|
18
|
+
export type FakeHandler = (
|
|
19
|
+
request: ModelRequest<unknown>,
|
|
20
|
+
index: number,
|
|
21
|
+
) => unknown | Promise<unknown>;
|
|
22
|
+
|
|
23
|
+
/** Provider roteirizado. */
|
|
24
|
+
export class FakeModelProvider implements ModelProvider {
|
|
25
|
+
readonly name = "deepseek" as const;
|
|
26
|
+
readonly modelId: string;
|
|
27
|
+
readonly requests: ModelRequest<unknown>[] = [];
|
|
28
|
+
calls = 0;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly handler: FakeHandler,
|
|
32
|
+
modelId = "fake-model",
|
|
33
|
+
) {
|
|
34
|
+
this.modelId = modelId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async generate<T>(request: ModelRequest<T>): Promise<ModelResponse<T>> {
|
|
38
|
+
this.requests.push(request as ModelRequest<unknown>);
|
|
39
|
+
const index = this.calls++;
|
|
40
|
+
const output = await this.handler(request as ModelRequest<unknown>, index);
|
|
41
|
+
return {
|
|
42
|
+
output: output as T,
|
|
43
|
+
usage: fakeUsage(),
|
|
44
|
+
steps: 1,
|
|
45
|
+
finishReason: "stop",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -294,6 +294,15 @@ describe("selectSubmitCandidates", () => {
|
|
|
294
294
|
expect(selectSubmitCandidates(state, { execStates: ["done"] })).toEqual([]);
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
test("filtra pela decisão (a executar vs ainda pendente)", () => {
|
|
298
|
+
const execute = selectSubmitCandidates(state, { decisions: ["execute"] });
|
|
299
|
+
expect(execute.map((c) => c.id)).toEqual(["a"]);
|
|
300
|
+
expect(execute[0].decision).toBe("execute");
|
|
301
|
+
expect(
|
|
302
|
+
selectSubmitCandidates(state, { decisions: ["pending"] }).map((c) => c.id),
|
|
303
|
+
).toEqual(["c"]);
|
|
304
|
+
});
|
|
305
|
+
|
|
297
306
|
test("marca sent/skip e respeita includeSent", () => {
|
|
298
307
|
const sent = stateWith([finding({ id: "a", commentId: 99 })]);
|
|
299
308
|
const [only] = selectSubmitCandidates(sent);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReviewAgent — máquina de estados com provider fake e tools reais.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { SilentReporter } from "../harness/agent/agent-reporter";
|
|
10
|
+
import { ReviewAgent } from "../harness/agent/review-agent";
|
|
11
|
+
import { fakeAgentContext, fakeAgentDeps } from "./fixtures/fake-investigation";
|
|
12
|
+
import { FakeModelProvider } from "./fixtures/fake-model-provider";
|
|
13
|
+
|
|
14
|
+
const RULE = "Nunca usar `any`.";
|
|
15
|
+
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-agent-"));
|
|
17
|
+
mkdirSync(join(dir, "src/alpha"), { recursive: true });
|
|
18
|
+
writeFileSync(join(dir, "src/alpha/a.ts"), "const x: any = 1;\nconsole.log(x);\n");
|
|
19
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
20
|
+
|
|
21
|
+
/** Provider que investiga com `read_file_range` e devolve um draft. */
|
|
22
|
+
function investigatingProvider(options: { withEvidence?: boolean } = {}) {
|
|
23
|
+
return new FakeModelProvider(async (request, index) => {
|
|
24
|
+
if (index === 0) return { hypotheses: ["uso de any"] };
|
|
25
|
+
if (index === 1) return { taskIds: ["rule-0"] };
|
|
26
|
+
const tool = request.tools?.read_file_range;
|
|
27
|
+
if (!tool) throw new Error("tool read_file_range ausente");
|
|
28
|
+
const raw = await tool.execute({
|
|
29
|
+
path: "src/alpha/a.ts",
|
|
30
|
+
startLine: 1,
|
|
31
|
+
endLine: 2,
|
|
32
|
+
});
|
|
33
|
+
const parsed = JSON.parse(raw) as {
|
|
34
|
+
evidence?: { id: string }[];
|
|
35
|
+
};
|
|
36
|
+
const evidenceId = parsed.evidence?.[0]?.id;
|
|
37
|
+
return {
|
|
38
|
+
findingDrafts: [
|
|
39
|
+
{
|
|
40
|
+
rule: RULE,
|
|
41
|
+
severity: "blocking",
|
|
42
|
+
file: "src/alpha/a.ts",
|
|
43
|
+
line: 1,
|
|
44
|
+
message: "Uso de any.",
|
|
45
|
+
action: "Tipar o valor.",
|
|
46
|
+
evidenceIds:
|
|
47
|
+
options.withEvidence === false || !evidenceId ? [] : [evidenceId],
|
|
48
|
+
confidence: 0.9,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
coverage: [
|
|
52
|
+
{ rule: RULE, status: "checked" },
|
|
53
|
+
{ rule: "Validar entrada do usuário.", status: "not_applicable" },
|
|
54
|
+
{ rule: "Evitar console.log.", status: "checked" },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildAgent(provider: FakeModelProvider): {
|
|
61
|
+
agent: ReviewAgent;
|
|
62
|
+
reporter: SilentReporter;
|
|
63
|
+
} {
|
|
64
|
+
const reporter = new SilentReporter();
|
|
65
|
+
const deps = fakeAgentDeps(provider, {
|
|
66
|
+
context: fakeAgentContext({ repoRoot: dir }),
|
|
67
|
+
reporter,
|
|
68
|
+
});
|
|
69
|
+
return { agent: new ReviewAgent(deps), reporter };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("ReviewAgent", () => {
|
|
73
|
+
test("ORIENT→PLAN→INVESTIGATE→VERIFY→CONCLUDE com evidência", async () => {
|
|
74
|
+
const { agent, reporter } = buildAgent(investigatingProvider());
|
|
75
|
+
const result = await agent.run();
|
|
76
|
+
expect(result.status).toBe("done");
|
|
77
|
+
expect(result.report?.findings).toHaveLength(1);
|
|
78
|
+
expect(result.report?.findings[0]?.evidence).toContain("src/alpha/a.ts");
|
|
79
|
+
expect(result.report?.reviewedFiles).toContain("src/alpha/a.ts");
|
|
80
|
+
const transitions = reporter.journal.transitions.map((item) => item.to);
|
|
81
|
+
expect(transitions).toEqual(["PLAN", "INVESTIGATE", "VERIFY", "CONCLUDE"]);
|
|
82
|
+
expect(result.state.coverage.size).toBe(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("VERIFY é obrigatório: draft sem evidência é descartado", async () => {
|
|
86
|
+
const { agent } = buildAgent(investigatingProvider({ withEvidence: false }));
|
|
87
|
+
const result = await agent.run();
|
|
88
|
+
expect(result.status).toBe("done");
|
|
89
|
+
expect(result.report?.findings).toHaveLength(0);
|
|
90
|
+
const coverage = result.state.coverageOf(RULE);
|
|
91
|
+
expect(coverage?.status).toBe("checked");
|
|
92
|
+
expect(coverage?.reason).toContain("refutado");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("toda regra termina com cobertura (mesmo sem o modelo cobrir)", async () => {
|
|
96
|
+
const provider = new FakeModelProvider((_request, index) => {
|
|
97
|
+
if (index === 0) return { hypotheses: [] };
|
|
98
|
+
if (index === 1) return { taskIds: [] };
|
|
99
|
+
return { findingDrafts: [], coverage: [] };
|
|
100
|
+
});
|
|
101
|
+
const { agent } = buildAgent(provider);
|
|
102
|
+
const result = await agent.run();
|
|
103
|
+
expect(result.state.coverage.size).toBe(3);
|
|
104
|
+
expect(
|
|
105
|
+
[...result.state.coverage.values()].every(
|
|
106
|
+
(item) => item.status === "skipped",
|
|
107
|
+
),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("falha do provider vira status failed", async () => {
|
|
112
|
+
const provider = new FakeModelProvider((_request, index) => {
|
|
113
|
+
if (index === 0) throw new Error("boom");
|
|
114
|
+
if (index === 1) throw new Error("boom");
|
|
115
|
+
throw new Error("429 rate limit");
|
|
116
|
+
});
|
|
117
|
+
const { agent } = buildAgent(provider);
|
|
118
|
+
const result = await agent.run();
|
|
119
|
+
expect(result.status).toBe("failed");
|
|
120
|
+
expect(result.error).toContain("429");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Investigação: planner, evidência, crítico e cobertura.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { AgentState } from "../harness/investigation/agent-state";
|
|
10
|
+
import { EvidenceVerifier } from "../harness/investigation/evidence-verifier";
|
|
11
|
+
import { FindingCritic } from "../harness/investigation/finding-critic";
|
|
12
|
+
import { InvestigationPlanner } from "../harness/investigation/investigation-planner";
|
|
13
|
+
import { RuleAnalyzer } from "../harness/investigation/rule-analyzer";
|
|
14
|
+
|
|
15
|
+
const RULE = "Nunca usar `any`.";
|
|
16
|
+
const analyses = new RuleAnalyzer().analyze([
|
|
17
|
+
{ severity: "suggestion", text: "Evitar console.log." },
|
|
18
|
+
{ severity: "blocking", text: RULE },
|
|
19
|
+
{ severity: "attention", text: "Validar entrada do usuário." },
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-evidence-"));
|
|
23
|
+
const file = join(dir, "a.ts");
|
|
24
|
+
writeFileSync(file, "const x: any = 1;\nconsole.log(x);\n");
|
|
25
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
26
|
+
|
|
27
|
+
function verifier(): EvidenceVerifier {
|
|
28
|
+
return new EvidenceVerifier({
|
|
29
|
+
allowedFiles: ["a.ts"],
|
|
30
|
+
readFile: (path) =>
|
|
31
|
+
path === "a.ts" ? "const x: any = 1;\nconsole.log(x);\n" : null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("InvestigationPlanner", () => {
|
|
36
|
+
test("prioriza por severidade e calcula estimativa", () => {
|
|
37
|
+
const plan = new InvestigationPlanner().plan(
|
|
38
|
+
{ watchdogId: "alpha", files: ["a.ts"], symbols: [] },
|
|
39
|
+
analyses,
|
|
40
|
+
);
|
|
41
|
+
expect(plan.tasks[0]?.rule).toBe(RULE);
|
|
42
|
+
expect(plan.tasks[0]?.severity).toBe("blocking");
|
|
43
|
+
expect(plan.totalFiles).toBe(1);
|
|
44
|
+
expect(plan.estimatedToolCalls).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("filtra arquivos por hint de path", () => {
|
|
48
|
+
const [analysis] = new RuleAnalyzer().analyze([
|
|
49
|
+
{
|
|
50
|
+
severity: "attention",
|
|
51
|
+
text: "Evitar: query no banco em rota.",
|
|
52
|
+
hints: { paths: ["^src/alpha/"] },
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
const plan = new InvestigationPlanner().plan(
|
|
56
|
+
{
|
|
57
|
+
watchdogId: "alpha",
|
|
58
|
+
files: ["src/alpha/a.ts", "src/beta/b.ts"],
|
|
59
|
+
symbols: [],
|
|
60
|
+
},
|
|
61
|
+
analysis ? [analysis] : [],
|
|
62
|
+
);
|
|
63
|
+
expect(plan.tasks[0]?.files).toEqual(["src/alpha/a.ts"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("EvidenceVerifier", () => {
|
|
68
|
+
test("aceita snippet na linha e reancora quando deslocado", () => {
|
|
69
|
+
const state = new AgentState();
|
|
70
|
+
const exact = state.addEvidence({
|
|
71
|
+
file: "a.ts",
|
|
72
|
+
startLine: 1,
|
|
73
|
+
endLine: 1,
|
|
74
|
+
snippet: "const x: any = 1;",
|
|
75
|
+
kind: "definition",
|
|
76
|
+
sourceTool: "t",
|
|
77
|
+
confidence: 0.9,
|
|
78
|
+
});
|
|
79
|
+
const moved = state.addEvidence({
|
|
80
|
+
file: "a.ts",
|
|
81
|
+
startLine: 9,
|
|
82
|
+
endLine: 9,
|
|
83
|
+
snippet: "console.log(x);",
|
|
84
|
+
kind: "reference",
|
|
85
|
+
sourceTool: "t",
|
|
86
|
+
confidence: 0.7,
|
|
87
|
+
});
|
|
88
|
+
expect(verifier().verify(exact).valid).toBe(true);
|
|
89
|
+
const verdict = verifier().verify(moved);
|
|
90
|
+
expect(verdict.valid).toBe(true);
|
|
91
|
+
expect(verdict.startLine).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("rejeita arquivo fora do domínio e snippet inexistente", () => {
|
|
95
|
+
const state = new AgentState();
|
|
96
|
+
const outside = state.addEvidence({
|
|
97
|
+
file: "b.ts",
|
|
98
|
+
startLine: 1,
|
|
99
|
+
endLine: 1,
|
|
100
|
+
snippet: "x",
|
|
101
|
+
kind: "definition",
|
|
102
|
+
sourceTool: "t",
|
|
103
|
+
confidence: 1,
|
|
104
|
+
});
|
|
105
|
+
const missing = state.addEvidence({
|
|
106
|
+
file: "a.ts",
|
|
107
|
+
startLine: 1,
|
|
108
|
+
endLine: 1,
|
|
109
|
+
snippet: "não existe",
|
|
110
|
+
kind: "definition",
|
|
111
|
+
sourceTool: "t",
|
|
112
|
+
confidence: 1,
|
|
113
|
+
});
|
|
114
|
+
expect(verifier().verify(outside).valid).toBe(false);
|
|
115
|
+
expect(verifier().verify(missing).valid).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("draft sem evidência válida é inválido", () => {
|
|
119
|
+
const state = new AgentState();
|
|
120
|
+
const evidence = state.addEvidence({
|
|
121
|
+
file: "a.ts",
|
|
122
|
+
startLine: 1,
|
|
123
|
+
endLine: 1,
|
|
124
|
+
snippet: "const x: any = 1;",
|
|
125
|
+
kind: "definition",
|
|
126
|
+
sourceTool: "t",
|
|
127
|
+
confidence: 0.9,
|
|
128
|
+
});
|
|
129
|
+
const draft = {
|
|
130
|
+
rule: RULE,
|
|
131
|
+
severity: "blocking" as const,
|
|
132
|
+
file: "a.ts",
|
|
133
|
+
line: 1,
|
|
134
|
+
message: "m",
|
|
135
|
+
action: "a",
|
|
136
|
+
evidenceIds: [evidence.id],
|
|
137
|
+
confidence: 0.9,
|
|
138
|
+
};
|
|
139
|
+
expect(verifier().verifyDraft(draft, state).valid).toBe(true);
|
|
140
|
+
expect(
|
|
141
|
+
verifier().verifyDraft({ ...draft, evidenceIds: ["nope"] }, state).valid,
|
|
142
|
+
).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("FindingCritic", () => {
|
|
147
|
+
const state = new AgentState();
|
|
148
|
+
const evidence = state.addEvidence({
|
|
149
|
+
file: "a.ts",
|
|
150
|
+
startLine: 1,
|
|
151
|
+
endLine: 1,
|
|
152
|
+
snippet: "const x: any = 1;",
|
|
153
|
+
kind: "definition",
|
|
154
|
+
sourceTool: "t",
|
|
155
|
+
confidence: 0.9,
|
|
156
|
+
});
|
|
157
|
+
const critic = new FindingCritic({
|
|
158
|
+
knownRules: new Set([RULE]),
|
|
159
|
+
verifier: verifier(),
|
|
160
|
+
});
|
|
161
|
+
const draft = {
|
|
162
|
+
rule: RULE,
|
|
163
|
+
severity: "blocking" as const,
|
|
164
|
+
file: "a.ts",
|
|
165
|
+
line: 1,
|
|
166
|
+
message: "m",
|
|
167
|
+
action: "a",
|
|
168
|
+
evidenceIds: [evidence.id],
|
|
169
|
+
confidence: 0.9,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
test("keep com evidência e confiança", () => {
|
|
173
|
+
expect(critic.reviewOne(draft, state).verdict).toBe("keep");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("drop sem evidência, sem regra ou confiança baixa", () => {
|
|
177
|
+
expect(critic.reviewOne({ ...draft, evidenceIds: [] }, state).verdict).toBe(
|
|
178
|
+
"drop",
|
|
179
|
+
);
|
|
180
|
+
expect(critic.reviewOne({ ...draft, rule: "inexistente" }, state).verdict).toBe(
|
|
181
|
+
"drop",
|
|
182
|
+
);
|
|
183
|
+
expect(critic.reviewOne({ ...draft, confidence: 0.2 }, state).verdict).toBe(
|
|
184
|
+
"drop",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("downgrade quando a confiança não sustenta a severidade", () => {
|
|
189
|
+
expect(critic.reviewOne({ ...draft, confidence: 0.5 }, state).verdict).toBe(
|
|
190
|
+
"downgrade",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("AgentState", () => {
|
|
196
|
+
test("cobertura e hipóteses sem duplicar", () => {
|
|
197
|
+
const state = new AgentState();
|
|
198
|
+
state.addHypothesis("a");
|
|
199
|
+
state.addHypothesis("a");
|
|
200
|
+
expect(state.hypotheses).toEqual(["a"]);
|
|
201
|
+
state.setCoverage(RULE, "skipped", "motivo");
|
|
202
|
+
expect(state.isCovered(RULE)).toBe(true);
|
|
203
|
+
state.setCoverage(RULE, "checked");
|
|
204
|
+
expect(state.coverageOf(RULE)?.status).toBe("checked");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools do harness: path guard, limites, cache, budget e redaction.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { AstService } from "../core/ast/service";
|
|
10
|
+
import { Watchdog } from "../core/domain/watchdog";
|
|
11
|
+
import { SilentReporter } from "../harness/agent/agent-reporter";
|
|
12
|
+
import { AgentState } from "../harness/investigation/agent-state";
|
|
13
|
+
import type { ToolContext } from "../harness/tools/agent-tool";
|
|
14
|
+
import { BuiltinToolsModule } from "../harness/tools/builtin";
|
|
15
|
+
import { PathGuard } from "../harness/tools/path-guard";
|
|
16
|
+
import { redactSecrets } from "../harness/tools/secret-redactor";
|
|
17
|
+
import { ToolCache } from "../harness/tools/tool-cache";
|
|
18
|
+
import { ToolCallBudget } from "../harness/tools/tool-call-budget";
|
|
19
|
+
import { ToolMiddleware } from "../harness/tools/tool-middleware";
|
|
20
|
+
import { ToolRegistry } from "../harness/tools/tool-registry";
|
|
21
|
+
import { EchoTool } from "./fixtures/fake-agent-tools";
|
|
22
|
+
import { fakeAgentContext } from "./fixtures/fake-investigation";
|
|
23
|
+
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
24
|
+
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-tools-"));
|
|
26
|
+
mkdirSync(join(dir, "src/alpha"), { recursive: true });
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(dir, "src/alpha/a.ts"),
|
|
29
|
+
"const x: any = 1;\nconsole.log(x);\nconst y = 2;\n",
|
|
30
|
+
);
|
|
31
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
32
|
+
|
|
33
|
+
function context(): ToolContext {
|
|
34
|
+
const state = new AgentState();
|
|
35
|
+
return {
|
|
36
|
+
repoRoot: dir,
|
|
37
|
+
watchdog: new Watchdog(fakeHoundsmanConfig.watchdogs.alpha),
|
|
38
|
+
diff: { byFile: new Map(), churn: new Map() },
|
|
39
|
+
ast: new AstService(fakeHoundsmanConfig),
|
|
40
|
+
state,
|
|
41
|
+
budget: new ToolCallBudget(20, 10),
|
|
42
|
+
cache: new ToolCache(),
|
|
43
|
+
logger: new SilentReporter(),
|
|
44
|
+
agent: fakeAgentContext({ repoRoot: dir }),
|
|
45
|
+
rules: new Map(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("PathGuard", () => {
|
|
50
|
+
test("bloqueia traversal, .git e .env", () => {
|
|
51
|
+
const guard = new PathGuard(dir);
|
|
52
|
+
expect(() => guard.resolvePath("../segredo")).toThrow();
|
|
53
|
+
expect(() => guard.resolvePath(".git/config")).toThrow();
|
|
54
|
+
expect(() => guard.resolvePath(".env")).toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("aceita caminho interno", () => {
|
|
58
|
+
const guard = new PathGuard(dir);
|
|
59
|
+
expect(guard.resolvePath("src/alpha/a.ts")).toContain("a.ts");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("read_file_range", () => {
|
|
64
|
+
test("devolve faixa numerada + evidência com id", async () => {
|
|
65
|
+
const registry = new ToolRegistry();
|
|
66
|
+
registry.registerModule(new BuiltinToolsModule());
|
|
67
|
+
const middleware = new ToolMiddleware();
|
|
68
|
+
const ctx = context();
|
|
69
|
+
const result = await registry.execute(
|
|
70
|
+
"read_file_range",
|
|
71
|
+
{ path: "src/alpha/a.ts", startLine: 1, endLine: 2 },
|
|
72
|
+
ctx,
|
|
73
|
+
middleware,
|
|
74
|
+
);
|
|
75
|
+
expect(result.ok).toBe(true);
|
|
76
|
+
const data = result.data as { content: string };
|
|
77
|
+
expect(data.content).toContain("1\tconst x: any = 1;");
|
|
78
|
+
expect(data.content).toContain("2\tconsole.log(x);");
|
|
79
|
+
expect(result.evidence?.[0]?.id).toBeTruthy();
|
|
80
|
+
expect(ctx.state.evidence.size).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("caminho fora do repo vira erro estruturado", async () => {
|
|
84
|
+
const registry = new ToolRegistry();
|
|
85
|
+
registry.registerModule(new BuiltinToolsModule());
|
|
86
|
+
const result = await registry.execute(
|
|
87
|
+
"read_file_range",
|
|
88
|
+
{ path: "../x", startLine: 1, endLine: 2 },
|
|
89
|
+
context(),
|
|
90
|
+
new ToolMiddleware(),
|
|
91
|
+
);
|
|
92
|
+
expect(result.ok).toBe(false);
|
|
93
|
+
expect(result.error).toContain("fora do repositório");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("ToolRegistry / middleware", () => {
|
|
98
|
+
test("nome duplicado falha no registro", () => {
|
|
99
|
+
const registry = new ToolRegistry();
|
|
100
|
+
registry.registerTool(new EchoTool());
|
|
101
|
+
expect(() => registry.registerTool(new EchoTool())).toThrow();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("resultado em cache não consome budget", async () => {
|
|
105
|
+
const registry = new ToolRegistry();
|
|
106
|
+
registry.registerTool(new EchoTool());
|
|
107
|
+
const middleware = new ToolMiddleware();
|
|
108
|
+
const ctx = context();
|
|
109
|
+
await registry.execute("echo", { value: "a" }, ctx, middleware);
|
|
110
|
+
expect(ctx.budget.total).toBe(1);
|
|
111
|
+
await registry.execute("echo", { value: "a" }, ctx, middleware);
|
|
112
|
+
expect(ctx.budget.total).toBe(1);
|
|
113
|
+
await registry.execute("echo", { value: "b" }, ctx, middleware);
|
|
114
|
+
expect(ctx.budget.total).toBe(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("input inválido vira erro (Zod)", async () => {
|
|
118
|
+
const registry = new ToolRegistry();
|
|
119
|
+
registry.registerTool(new EchoTool());
|
|
120
|
+
const result = await registry.execute(
|
|
121
|
+
"echo",
|
|
122
|
+
{ value: 1 },
|
|
123
|
+
context(),
|
|
124
|
+
new ToolMiddleware(),
|
|
125
|
+
);
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
expect(result.error).toContain("input inválido");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("budget esgotado interrompe", async () => {
|
|
131
|
+
const registry = new ToolRegistry();
|
|
132
|
+
registry.registerTool(new EchoTool());
|
|
133
|
+
const ctx = context();
|
|
134
|
+
ctx.budget = new ToolCallBudget(1, 1);
|
|
135
|
+
const middleware = new ToolMiddleware();
|
|
136
|
+
await registry.execute("echo", { value: "a" }, ctx, middleware);
|
|
137
|
+
const second = await registry.execute("echo", { value: "b" }, ctx, middleware);
|
|
138
|
+
expect(second.ok).toBe(false);
|
|
139
|
+
expect(second.error).toContain("Orçamento");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("redaction", () => {
|
|
144
|
+
test("remove tokens e chaves", () => {
|
|
145
|
+
expect(redactSecrets("token sk-abcdefgh123")).toContain("[REDACTED]");
|
|
146
|
+
expect(redactSecrets("api_key = segredo")).toContain("[REDACTED]");
|
|
147
|
+
});
|
|
148
|
+
});
|