@payconductor/houndsman 0.4.0 → 0.6.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 +35 -41
- 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/status-command.ts +46 -0
- package/src/cli/help.ts +18 -1
- package/src/cli/index.ts +13 -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 +120 -6
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +159 -10
- package/src/core/commands/status.ts +63 -0
- 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 +77 -13
- 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/findings-state.ts +21 -4
- package/src/core/github-review.ts +620 -57
- 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 +454 -16
- package/src/core/paths.ts +3 -0
- 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/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/rule-text.ts +97 -0
- package/src/core/run-status.ts +39 -5
- package/src/core/services/file-classifier.ts +10 -3
- package/src/core/services/verifier.ts +14 -3
- package/src/core/storage/file/file-storage.ts +2 -0
- package/src/core/storage/paths.ts +6 -5
- package/src/core/storage/sqlite/mappers.ts +2 -0
- package/src/core/storage/sqlite/schema.ts +9 -1
- package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
- package/src/core/storage/types.ts +15 -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 +26 -9
- package/src/server/client/ArtifactsPanel.tsx +12 -5
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +14 -10
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +92 -12
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +49 -6
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/{constants.ts → utils/constants.ts} +61 -10
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +64 -0
- package/src/server/index.ts +66 -7
- package/src/server/run-repository.ts +9 -0
- package/src/skill/OUTPUT-FORMAT.md +12 -0
- package/src/skill/SETUP.md +6 -2
- package/src/skill/example-review.md +22 -14
- package/src/skill/hunt.md +45 -19
- package/src/skill/verify.md +3 -2
- package/src/tests/__snapshots__/baseline.test.ts.snap +126 -42
- 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/baseline.test.ts +3 -0
- package/src/tests/code-highlight.test.ts +1 -1
- package/src/tests/env.test.ts +69 -0
- package/src/tests/fallback.test.ts +172 -0
- package/src/tests/findings-state.test.ts +50 -2
- 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 +409 -60
- 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/markdown.test.ts +61 -1
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +73 -0
- package/src/tests/rule-analyzer.test.ts +61 -0
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +53 -0
- package/src/tests/server.test.ts +76 -21
- package/src/tests/storage.test.ts +63 -0
- package/src/tests/tool-contracts.test.ts +82 -0
- package/src/tests/verify.test.ts +1 -1
- package/src/server/client/markdown.ts +0 -153
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
- /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testes do subsistema AST (project-agnóstico).
|
|
3
|
+
*
|
|
4
|
+
* Cobrem extração de fatos, delta por hunk, impacto (resolução de imports),
|
|
5
|
+
* classificação estrutural e o bloco injetado no brief. Nenhuma chamada de rede.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { computeDelta, parseHunks } from "../core/ast/diff";
|
|
13
|
+
import { resolveImport } from "../core/ast/impact";
|
|
14
|
+
import { AstParser } from "../core/ast/parser";
|
|
15
|
+
import { AstService } from "../core/ast/service";
|
|
16
|
+
import type { AstFacts } from "../core/ast/types";
|
|
17
|
+
import { renderBrief } from "../core/brief";
|
|
18
|
+
import { defineHoundsmanConfig } from "../core/config";
|
|
19
|
+
import { collectDiff } from "../core/diff-source";
|
|
20
|
+
import { matchWatchdogs, parseEntries } from "../core/file-classifier";
|
|
21
|
+
import { runGit } from "../core/git-runner";
|
|
22
|
+
import { GitRepository } from "../core/services/git-repository";
|
|
23
|
+
import { defineWatchdog } from "../core/types";
|
|
24
|
+
|
|
25
|
+
const parser = new AstParser();
|
|
26
|
+
|
|
27
|
+
const TS_SOURCE = `import { db } from "./db";
|
|
28
|
+
import type { Order } from "./types";
|
|
29
|
+
|
|
30
|
+
export class OrderService {
|
|
31
|
+
constructor(private readonly database: Db) {}
|
|
32
|
+
|
|
33
|
+
async create(input: Input): Promise<Order> {
|
|
34
|
+
return db.insert(input);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function helper(value: string): number {
|
|
39
|
+
return value.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const handler = async (request: Request) => {
|
|
43
|
+
return helper(request.url);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
@Controller("/orders")
|
|
47
|
+
export class OrdersController {
|
|
48
|
+
@Get(":id")
|
|
49
|
+
get(): string {
|
|
50
|
+
return "ok";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
async function factsOf(source: string): Promise<AstFacts> {
|
|
56
|
+
const facts = await parser.parseSource("typescript", source);
|
|
57
|
+
if (!facts) throw new Error("parse falhou");
|
|
58
|
+
return facts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("AstParser / extractFacts", () => {
|
|
62
|
+
test("extrai símbolos, exports, imports e decorators", async () => {
|
|
63
|
+
const facts = await factsOf(TS_SOURCE);
|
|
64
|
+
const names = facts.symbols.map((symbol) => symbol.name);
|
|
65
|
+
|
|
66
|
+
expect(names).toContain("OrderService");
|
|
67
|
+
expect(names).toContain("OrderService.create");
|
|
68
|
+
expect(names).toContain("helper");
|
|
69
|
+
expect(names).toContain("handler");
|
|
70
|
+
expect(facts.imports.map((item) => item.source)).toEqual(["./db", "./types"]);
|
|
71
|
+
expect(facts.imports[1]?.typeOnly).toBe(true);
|
|
72
|
+
expect(facts.exports.map((item) => item.name)).toEqual(
|
|
73
|
+
expect.arrayContaining([
|
|
74
|
+
"OrderService",
|
|
75
|
+
"helper",
|
|
76
|
+
"handler",
|
|
77
|
+
"OrdersController",
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
expect(facts.decorators).toEqual(["Controller", "Get"]);
|
|
81
|
+
expect(facts.hasSyntaxErrors).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("não quebra com sintaxe inválida (fatos parciais)", async () => {
|
|
85
|
+
const facts = await factsOf("export function broken( {");
|
|
86
|
+
expect(facts).not.toBeNull();
|
|
87
|
+
expect(facts.hasSyntaxErrors).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("não extrai variáveis aninhadas como símbolos de topo", async () => {
|
|
91
|
+
const facts = await factsOf(
|
|
92
|
+
"export const handler = () => { const inner = 1; return inner; };\n",
|
|
93
|
+
);
|
|
94
|
+
const names = facts.symbols.map((symbol) => symbol.name);
|
|
95
|
+
expect(names).toContain("handler");
|
|
96
|
+
expect(names).not.toContain("inner");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("linguagem não suportada devolve null", async () => {
|
|
100
|
+
expect(await parser.parseFile("README.md", "# titulo")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("computeDelta / parseHunks", () => {
|
|
105
|
+
test("parseHunks extrai linhas novas", () => {
|
|
106
|
+
const patch = [
|
|
107
|
+
"@@ -1,3 +1,4 @@",
|
|
108
|
+
" export function a() {",
|
|
109
|
+
"- return 1;",
|
|
110
|
+
"+ const x = 1;",
|
|
111
|
+
"+ return x;",
|
|
112
|
+
" }",
|
|
113
|
+
].join("\n");
|
|
114
|
+
expect(parseHunks(patch)[0]?.addedLines).toEqual([2, 3]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("detecta símbolos adicionados/removidos e assinatura alterada", async () => {
|
|
118
|
+
const before = await factsOf(
|
|
119
|
+
"export function a(value: string): void {}\nexport const b = 1;\n",
|
|
120
|
+
);
|
|
121
|
+
const after = await factsOf(
|
|
122
|
+
"export function a(value: number, extra: boolean): void {}\nexport function c() {}\n",
|
|
123
|
+
);
|
|
124
|
+
const patch = [
|
|
125
|
+
"@@ -1,2 +1,2 @@",
|
|
126
|
+
"-export function a(value: string): void {}",
|
|
127
|
+
"+export function a(value: number, extra: boolean): void {}",
|
|
128
|
+
"-export const b = 1;",
|
|
129
|
+
"+export function c() {}",
|
|
130
|
+
].join("\n");
|
|
131
|
+
|
|
132
|
+
const delta = computeDelta(before, after, patch);
|
|
133
|
+
expect(delta.addedSymbols.map((symbol) => symbol.name)).toContain("c");
|
|
134
|
+
expect(delta.removedSymbols.map((symbol) => symbol.name)).toContain("b");
|
|
135
|
+
expect(delta.changedSignatures.map((item) => item.name)).toContain("a");
|
|
136
|
+
expect(delta.modifiedSymbols.map((symbol) => symbol.name)).toContain("a");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("marca como export a mudança via `export { ... }`", async () => {
|
|
140
|
+
const before = await factsOf(
|
|
141
|
+
"function a(value: string): void {}\nexport { a };\n",
|
|
142
|
+
);
|
|
143
|
+
const after = await factsOf(
|
|
144
|
+
"function a(value: number): void {}\nexport { a };\n",
|
|
145
|
+
);
|
|
146
|
+
const delta = computeDelta(
|
|
147
|
+
before,
|
|
148
|
+
after,
|
|
149
|
+
"@@ -1,2 +1,2 @@\n-function a(value: string): void {}\n+function a(value: number): void {}\n export { a };",
|
|
150
|
+
);
|
|
151
|
+
expect(delta.changedSignatures[0]?.exported).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("resolveImport", () => {
|
|
156
|
+
const aliases = { "@/*": "src/*" };
|
|
157
|
+
const existing = new Set([
|
|
158
|
+
"src/db.ts",
|
|
159
|
+
"src/orders/index.ts",
|
|
160
|
+
"src/utils/helpers.tsx",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
test("resolve relativo com extensão implícita", () => {
|
|
164
|
+
expect(
|
|
165
|
+
resolveImport("src/a.ts", "./db", aliases, (path) => existing.has(path)),
|
|
166
|
+
).toBe("src/db.ts");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("resolve alias e index", () => {
|
|
170
|
+
expect(
|
|
171
|
+
resolveImport("src/a.ts", "@/orders", aliases, (path) => existing.has(path)),
|
|
172
|
+
).toBe("src/orders/index.ts");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("ignora pacote externo", () => {
|
|
176
|
+
expect(
|
|
177
|
+
resolveImport("src/a.ts", "react", aliases, (path) => existing.has(path)),
|
|
178
|
+
).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("classificação estrutural", () => {
|
|
183
|
+
test("casa regras por símbolo, import, export e decorator", async () => {
|
|
184
|
+
const facts = await factsOf(TS_SOURCE);
|
|
185
|
+
const watchdog = defineWatchdog({
|
|
186
|
+
id: "auth",
|
|
187
|
+
name: "Auth",
|
|
188
|
+
description: "Regras de autenticação.",
|
|
189
|
+
scope: "src/**",
|
|
190
|
+
classify: [
|
|
191
|
+
{ type: "decorator", value: "^Controller$" },
|
|
192
|
+
{ type: "import", value: "^\\./db$" },
|
|
193
|
+
{ type: "symbol", value: "OrderService" },
|
|
194
|
+
{ type: "export", value: "^OrdersController$" },
|
|
195
|
+
],
|
|
196
|
+
rules: [{ severity: "attention", text: "Promover: validar entrada." }],
|
|
197
|
+
contextFiles: ["AGENTS.md"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(
|
|
201
|
+
matchWatchdogs(
|
|
202
|
+
"src/orders.ts",
|
|
203
|
+
{ auth: watchdog },
|
|
204
|
+
["auth"],
|
|
205
|
+
undefined,
|
|
206
|
+
facts,
|
|
207
|
+
),
|
|
208
|
+
).toEqual(["auth"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("sem fatos AST, regras estruturais não casam", () => {
|
|
212
|
+
const watchdog = defineWatchdog({
|
|
213
|
+
id: "auth",
|
|
214
|
+
name: "Auth",
|
|
215
|
+
description: "Regras de autenticação.",
|
|
216
|
+
scope: "src/**",
|
|
217
|
+
classify: [{ type: "decorator", value: "^Controller$" }],
|
|
218
|
+
rules: [{ severity: "attention", text: "Promover: validar entrada." }],
|
|
219
|
+
contextFiles: ["AGENTS.md"],
|
|
220
|
+
});
|
|
221
|
+
expect(matchWatchdogs("src/orders.ts", { auth: watchdog }, ["auth"])).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("brief", () => {
|
|
226
|
+
test("injeta a seção de estrutura com delta e impacto", async () => {
|
|
227
|
+
const before = await factsOf("export function a(value: string): void {}\n");
|
|
228
|
+
const after = await factsOf(
|
|
229
|
+
"export function a(value: number): void {}\nexport const b = 1;\n",
|
|
230
|
+
);
|
|
231
|
+
const delta = computeDelta(
|
|
232
|
+
before,
|
|
233
|
+
after,
|
|
234
|
+
"@@ -1 +1,2 @@\n-export function a(value: string): void {}\n+export function a(value: number): void {}\n+export const b = 1;",
|
|
235
|
+
);
|
|
236
|
+
const watchdog = defineWatchdog({
|
|
237
|
+
id: "alpha",
|
|
238
|
+
name: "Alpha",
|
|
239
|
+
description: "Watchdog de teste.",
|
|
240
|
+
scope: "src/**",
|
|
241
|
+
classify: [{ value: "^src/" }],
|
|
242
|
+
rules: [{ severity: "attention", text: "Promover: algo." }],
|
|
243
|
+
contextFiles: ["AGENTS.md"],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const brief = renderBrief(watchdog, {
|
|
247
|
+
watchdogId: "alpha",
|
|
248
|
+
files: ["src/a.ts"],
|
|
249
|
+
statuses: { "src/a.ts": { status: "modified" } },
|
|
250
|
+
deleted: [],
|
|
251
|
+
branch: "main",
|
|
252
|
+
outputPath: "/tmp/a.json",
|
|
253
|
+
outputFormatPath: "/tmp/OUTPUT-FORMAT.md",
|
|
254
|
+
docs: [],
|
|
255
|
+
ast: [{ file: "src/a.ts", delta, importers: ["src/b.ts"] }],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(brief).toContain("## Estrutura (AST)");
|
|
259
|
+
expect(brief).toContain("assinatura `a`");
|
|
260
|
+
expect(brief).toContain("impacto: `src/b.ts`");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("AstService (integração)", () => {
|
|
265
|
+
test("analisa delta e descobre importadores via git grep", async () => {
|
|
266
|
+
const cwd = mkdtempSync(join(tmpdir(), "houndsman-ast-"));
|
|
267
|
+
const originalCwd = process.cwd();
|
|
268
|
+
const git = (args: string[]): string => runGit(args);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
process.chdir(cwd);
|
|
272
|
+
git(["init"]);
|
|
273
|
+
git(["config", "user.email", "test@example.com"]);
|
|
274
|
+
git(["config", "user.name", "Test"]);
|
|
275
|
+
git(["config", "commit.gpgsign", "false"]);
|
|
276
|
+
mkdirSync("src", { recursive: true });
|
|
277
|
+
writeFileSync(
|
|
278
|
+
"src/db.ts",
|
|
279
|
+
"export function connect(url: string): string { return url; }\n",
|
|
280
|
+
);
|
|
281
|
+
writeFileSync(
|
|
282
|
+
"src/orders.ts",
|
|
283
|
+
'import { connect } from "./db";\nexport function load(): string { return connect("x"); }\n',
|
|
284
|
+
);
|
|
285
|
+
git(["add", "."]);
|
|
286
|
+
git(["commit", "-m", "base"]);
|
|
287
|
+
|
|
288
|
+
writeFileSync(
|
|
289
|
+
"src/db.ts",
|
|
290
|
+
"export function connect(url: string, timeout: number): string { return url; }\n",
|
|
291
|
+
);
|
|
292
|
+
git(["add", "."]);
|
|
293
|
+
git(["commit", "-m", "change"]);
|
|
294
|
+
|
|
295
|
+
const config = defineHoundsmanConfig({
|
|
296
|
+
projectName: "AST",
|
|
297
|
+
watchdogs: {
|
|
298
|
+
alpha: defineWatchdog({
|
|
299
|
+
id: "alpha",
|
|
300
|
+
name: "Alpha",
|
|
301
|
+
description: "Teste.",
|
|
302
|
+
scope: "src/**",
|
|
303
|
+
classify: [{ value: "^src/" }],
|
|
304
|
+
rules: [{ severity: "attention", text: "Promover: algo." }],
|
|
305
|
+
contextFiles: ["AGENTS.md"],
|
|
306
|
+
}),
|
|
307
|
+
},
|
|
308
|
+
exclusions: [],
|
|
309
|
+
docMappings: [],
|
|
310
|
+
ast: { aliases: {} },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const diff = collectDiff("HEAD~1", "HEAD");
|
|
314
|
+
const entries = parseEntries(git(["diff", "HEAD~1...HEAD", "--name-status"]));
|
|
315
|
+
const service = new AstService(config, new GitRepository());
|
|
316
|
+
const analyses = await service.analyze(entries, diff, "HEAD~1");
|
|
317
|
+
|
|
318
|
+
const changed = analyses.get("src/db.ts");
|
|
319
|
+
expect(changed?.delta?.changedSignatures[0]?.name).toBe("connect");
|
|
320
|
+
expect(changed?.delta?.changedSignatures[0]?.exported).toBe(true);
|
|
321
|
+
|
|
322
|
+
const impact = await service.impact(analyses);
|
|
323
|
+
expect(impact.importers["src/db.ts"]).toEqual(["src/orders.ts"]);
|
|
324
|
+
expect(impact.affectedFiles).toEqual(["src/orders.ts"]);
|
|
325
|
+
expect(impact.exportedChanges).toContainEqual({
|
|
326
|
+
file: "src/db.ts",
|
|
327
|
+
symbol: "connect",
|
|
328
|
+
change: "signature",
|
|
329
|
+
});
|
|
330
|
+
} finally {
|
|
331
|
+
process.chdir(originalCwd);
|
|
332
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}, 30_000);
|
|
335
|
+
});
|
|
@@ -432,6 +432,9 @@ describe("baseline — artefatos de prepare/finalize", () => {
|
|
|
432
432
|
const artifact = storage.readArtifact(review.id, name);
|
|
433
433
|
expect(normalize(artifact?.content ?? "")).toMatchSnapshot(name);
|
|
434
434
|
}
|
|
435
|
+
const resume = storage.readArtifact(review.id, "review-resume.md");
|
|
436
|
+
expect(resume?.kind).toBe("resume");
|
|
437
|
+
expect((resume?.content ?? "").length).toBeLessThanOrEqual(2500);
|
|
435
438
|
expect(
|
|
436
439
|
normalize(JSON.stringify(storage.readFindings(review.id), null, 2)),
|
|
437
440
|
).toMatchSnapshot("state.json");
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
highlightCode,
|
|
11
11
|
languageFromFile,
|
|
12
12
|
languageFromName,
|
|
13
|
-
} from "../server/client/code-highlight";
|
|
13
|
+
} from "../server/client/utils/code-highlight";
|
|
14
14
|
|
|
15
15
|
/** O texto concatenado dos tokens deve ser idêntico ao input. */
|
|
16
16
|
function joined(tokens: { text: string }[]): string {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testes do ambiente tipado (`core/env.ts`).
|
|
3
|
+
*
|
|
4
|
+
* Nenhum valor secreto é impresso; os testes apenas exercitam leitura,
|
|
5
|
+
* normalização e restauração das variáveis.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { ENV_NAMES, env, readEnv } from "../core/env";
|
|
10
|
+
|
|
11
|
+
const original = {
|
|
12
|
+
typeSafe: process.env[ENV_NAMES.typeSafeApiKey],
|
|
13
|
+
deepSeek: process.env[ENV_NAMES.deepSeekApiKey],
|
|
14
|
+
home: process.env[ENV_NAMES.home],
|
|
15
|
+
localAppData: process.env[ENV_NAMES.localAppData],
|
|
16
|
+
xdgDataHome: process.env[ENV_NAMES.xdgDataHome],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
restore(ENV_NAMES.typeSafeApiKey, original.typeSafe);
|
|
21
|
+
restore(ENV_NAMES.deepSeekApiKey, original.deepSeek);
|
|
22
|
+
restore(ENV_NAMES.home, original.home);
|
|
23
|
+
restore(ENV_NAMES.localAppData, original.localAppData);
|
|
24
|
+
restore(ENV_NAMES.xdgDataHome, original.xdgDataHome);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function restore(name: string, value: string | undefined): void {
|
|
28
|
+
if (value === undefined) delete process.env[name];
|
|
29
|
+
else process.env[name] = value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("env", () => {
|
|
33
|
+
test("lê as chaves canônicas, apara espaços e trata vazio como ausente", () => {
|
|
34
|
+
process.env[ENV_NAMES.typeSafeApiKey] = " key-1 ";
|
|
35
|
+
process.env[ENV_NAMES.deepSeekApiKey] = "";
|
|
36
|
+
process.env[ENV_NAMES.home] = " /tmp/houndsman ";
|
|
37
|
+
|
|
38
|
+
const snapshot = readEnv();
|
|
39
|
+
expect(snapshot.typeSafeApiKey).toBe("key-1");
|
|
40
|
+
expect(snapshot.deepSeekApiKey).toBeUndefined();
|
|
41
|
+
expect(snapshot.home).toBe("/tmp/houndsman");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("getters refletem alterações em runtime", () => {
|
|
45
|
+
process.env[ENV_NAMES.deepSeekApiKey] = "ds-1";
|
|
46
|
+
expect(env.deepSeekApiKey).toBe("ds-1");
|
|
47
|
+
process.env[ENV_NAMES.deepSeekApiKey] = "ds-2";
|
|
48
|
+
expect(env.deepSeekApiKey).toBe("ds-2");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("variável ausente vira undefined", () => {
|
|
52
|
+
delete process.env[ENV_NAMES.typeSafeApiKey];
|
|
53
|
+
expect(env.typeSafeApiKey).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("chaves de integração usam o prefixo HOUNDSMAN_", () => {
|
|
57
|
+
const native = new Set([
|
|
58
|
+
"LOCALAPPDATA",
|
|
59
|
+
"XDG_DATA_HOME",
|
|
60
|
+
"NO_COLOR",
|
|
61
|
+
"TERM",
|
|
62
|
+
"CI",
|
|
63
|
+
]);
|
|
64
|
+
for (const name of Object.values(ENV_NAMES)) {
|
|
65
|
+
if (native.has(name)) continue;
|
|
66
|
+
expect(name.startsWith("HOUNDSMAN_")).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback de classificação e findings emergentes (lógica/domínio).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { defineHoundsmanConfig } from "../core/config";
|
|
7
|
+
import { classifyEntries } from "../core/file-classifier";
|
|
8
|
+
import { checkRegistry } from "../core/registry-check";
|
|
9
|
+
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
|
+
|
|
15
|
+
const alpha = defineWatchdog({
|
|
16
|
+
id: "alpha",
|
|
17
|
+
name: "Alpha",
|
|
18
|
+
description: "Domínio alpha.",
|
|
19
|
+
scope: "src/alpha/**",
|
|
20
|
+
classify: [{ value: "^src/alpha/" }],
|
|
21
|
+
rules: [{ severity: "blocking", text: "Nunca usar any." }],
|
|
22
|
+
contextFiles: [],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const fallback = defineWatchdog({
|
|
26
|
+
id: "default",
|
|
27
|
+
name: "Padrões gerais",
|
|
28
|
+
description: "Fallback de lógica/domínio.",
|
|
29
|
+
scope: "arquivos sem dono",
|
|
30
|
+
fallback: true,
|
|
31
|
+
allowEmergentFindings: true,
|
|
32
|
+
rules: [
|
|
33
|
+
{
|
|
34
|
+
severity: "attention",
|
|
35
|
+
text: "Promover: a lógica do domínio respeita as invariantes.",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
contextFiles: [],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const config = defineHoundsmanConfig({
|
|
42
|
+
projectName: "Fallback",
|
|
43
|
+
watchdogs: { alpha, default: fallback },
|
|
44
|
+
exclusions: [],
|
|
45
|
+
docMappings: [],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("classificação com fallback", () => {
|
|
49
|
+
test("arquivo sem dono vai para o fallback; com dono fica no domínio", () => {
|
|
50
|
+
const result = classifyEntries(
|
|
51
|
+
[
|
|
52
|
+
{ path: "src/alpha/a.ts", status: "modified" },
|
|
53
|
+
{ path: "src/other/b.ts", status: "modified" },
|
|
54
|
+
],
|
|
55
|
+
config,
|
|
56
|
+
{},
|
|
57
|
+
);
|
|
58
|
+
expect(result.watchdogs.alpha).toEqual(["src/alpha/a.ts"]);
|
|
59
|
+
expect(result.watchdogs.default).toEqual(["src/other/b.ts"]);
|
|
60
|
+
expect(result.unclassified).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("múltiplos fallbacks geram warning (primeiro vence)", () => {
|
|
64
|
+
const second = defineWatchdog({
|
|
65
|
+
...fallback,
|
|
66
|
+
id: "default2",
|
|
67
|
+
});
|
|
68
|
+
const duplicated = defineHoundsmanConfig({
|
|
69
|
+
...config,
|
|
70
|
+
watchdogs: { ...config.watchdogs, default2: second },
|
|
71
|
+
});
|
|
72
|
+
const result = classifyEntries(
|
|
73
|
+
[{ path: "src/other/b.ts", status: "modified" }],
|
|
74
|
+
duplicated,
|
|
75
|
+
{},
|
|
76
|
+
);
|
|
77
|
+
expect(result.watchdogs.default).toEqual(["src/other/b.ts"]);
|
|
78
|
+
expect(result.watchdogs.default2).toBeUndefined();
|
|
79
|
+
const { warnings } = checkRegistry(duplicated);
|
|
80
|
+
expect(
|
|
81
|
+
warnings.some((item) => item.includes("Mais de um watchdog fallback")),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
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
|
+
});
|
|
@@ -112,7 +112,7 @@ describe("buildStateFromFindings", () => {
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
test("finding que sumiu
|
|
115
|
+
test("finding que sumiu vira done (corrigido entre revisões)", () => {
|
|
116
116
|
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
117
117
|
try {
|
|
118
118
|
const reader = fileReader(project.cwd);
|
|
@@ -121,7 +121,7 @@ describe("buildStateFromFindings", () => {
|
|
|
121
121
|
{ branch: "main", readFile: reader },
|
|
122
122
|
);
|
|
123
123
|
const id = Object.keys(first.findings)[0];
|
|
124
|
-
|
|
124
|
+
expect(first.findings[id].decision).toBe("pending");
|
|
125
125
|
|
|
126
126
|
const second = buildStateFromFindings([], {
|
|
127
127
|
branch: "main",
|
|
@@ -135,6 +135,54 @@ describe("buildStateFromFindings", () => {
|
|
|
135
135
|
}
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test("finding ausente e marcado como 'ignore' preserva o estado", () => {
|
|
139
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
140
|
+
try {
|
|
141
|
+
const reader = fileReader(project.cwd);
|
|
142
|
+
const first = buildStateFromFindings(
|
|
143
|
+
[merged({ file: "src/f.ts", rule: "r", line: 2 })],
|
|
144
|
+
{ branch: "main", readFile: reader },
|
|
145
|
+
);
|
|
146
|
+
const id = Object.keys(first.findings)[0];
|
|
147
|
+
applyFindingUpdate(first.findings[id], { decision: "ignore" });
|
|
148
|
+
|
|
149
|
+
const second = buildStateFromFindings([], {
|
|
150
|
+
branch: "main",
|
|
151
|
+
readFile: reader,
|
|
152
|
+
previous: first,
|
|
153
|
+
});
|
|
154
|
+
expect(second.findings[id].present).toBe(false);
|
|
155
|
+
expect(second.findings[id].execState).toBe("pending");
|
|
156
|
+
expect(second.findings[id].decision).toBe("ignore");
|
|
157
|
+
} finally {
|
|
158
|
+
project.cleanup();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("arquivo fora da revisão (orçamento/skipped) não vira done", () => {
|
|
163
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
164
|
+
try {
|
|
165
|
+
const reader = fileReader(project.cwd);
|
|
166
|
+
const first = buildStateFromFindings(
|
|
167
|
+
[merged({ file: "src/f.ts", rule: "r", line: 2 })],
|
|
168
|
+
{ branch: "main", readFile: reader },
|
|
169
|
+
);
|
|
170
|
+
const id = Object.keys(first.findings)[0];
|
|
171
|
+
|
|
172
|
+
const second = buildStateFromFindings([], {
|
|
173
|
+
branch: "main",
|
|
174
|
+
readFile: reader,
|
|
175
|
+
previous: first,
|
|
176
|
+
// Nenhum arquivo foi revisado neste ciclo: ausência não prova resolução.
|
|
177
|
+
reviewedPaths: new Set(["src/outro.ts"]),
|
|
178
|
+
});
|
|
179
|
+
expect(second.findings[id].present).toBe(false);
|
|
180
|
+
expect(second.findings[id].execState).toBe("pending");
|
|
181
|
+
} finally {
|
|
182
|
+
project.cleanup();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
138
186
|
test("ocorrências idênticas (mesmo contexto) viram um grupo com count", () => {
|
|
139
187
|
const content = [
|
|
140
188
|
"const a = 1;",
|