@payconductor/houndsman 0.5.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.
Files changed (153) hide show
  1. package/README.md +24 -4
  2. package/assets/grammars/tree-sitter-javascript.wasm +0 -0
  3. package/assets/grammars/tree-sitter-tsx.wasm +0 -0
  4. package/assets/grammars/tree-sitter-typescript.wasm +0 -0
  5. package/assets/grammars/web-tree-sitter.wasm +0 -0
  6. package/package.json +11 -3
  7. package/src/cli/commands/command.ts +5 -0
  8. package/src/cli/commands/doctor-command.ts +116 -24
  9. package/src/cli/commands/hunt-command.ts +25 -0
  10. package/src/cli/help.ts +17 -1
  11. package/src/cli/index.ts +10 -6
  12. package/src/core/ai/ai-client.ts +221 -0
  13. package/src/core/ai/ai-sdk-model-provider.ts +101 -0
  14. package/src/core/ai/errors.ts +36 -0
  15. package/src/core/ai/features/brief-enricher.ts +62 -0
  16. package/src/core/ai/features/classifier.ts +50 -0
  17. package/src/core/ai/features/context-summarizer.ts +31 -0
  18. package/src/core/ai/features/pre-analyzer.ts +51 -0
  19. package/src/core/ai/features/verifier.ts +45 -0
  20. package/src/core/ai/index.ts +56 -0
  21. package/src/core/ai/model-factory.ts +137 -0
  22. package/src/core/ai/prompts/base.ts +36 -0
  23. package/src/core/ai/prompts/brief.ts +87 -0
  24. package/src/core/ai/prompts/classification.ts +36 -0
  25. package/src/core/ai/prompts/context.ts +29 -0
  26. package/src/core/ai/prompts/pre-analysis.ts +49 -0
  27. package/src/core/ai/prompts/verify.ts +40 -0
  28. package/src/core/ai/schemas.ts +110 -0
  29. package/src/core/ai/types.ts +76 -0
  30. package/src/core/ai/usage-budget.ts +75 -0
  31. package/src/core/ai/usage-recorder.ts +79 -0
  32. package/src/core/assets.ts +16 -0
  33. package/src/core/ast/diff.ts +220 -0
  34. package/src/core/ast/impact.ts +127 -0
  35. package/src/core/ast/parser.ts +167 -0
  36. package/src/core/ast/service.ts +233 -0
  37. package/src/core/ast/symbols.ts +294 -0
  38. package/src/core/ast/types.ts +125 -0
  39. package/src/core/brief.ts +138 -0
  40. package/src/core/commands/findings.ts +59 -1
  41. package/src/core/commands/review.ts +2 -0
  42. package/src/core/commands/scaffold.ts +159 -10
  43. package/src/core/commands/update.ts +5 -3
  44. package/src/core/config.ts +85 -0
  45. package/src/core/defaults.ts +27 -0
  46. package/src/core/domain/watchdog.ts +43 -5
  47. package/src/core/engine/houndsman-engine.ts +16 -1
  48. package/src/core/engine/review-scope.ts +52 -6
  49. package/src/core/engine/review-session.ts +27 -2
  50. package/src/core/env.ts +101 -0
  51. package/src/core/file-classifier.ts +25 -4
  52. package/src/core/jev/jev-client.ts +8 -7
  53. package/src/core/jev/jev-verify.ts +2 -1
  54. package/src/core/logging/index.ts +17 -0
  55. package/src/core/logging/logger.ts +187 -0
  56. package/src/core/orchestrator.ts +357 -9
  57. package/src/core/project-config.ts +123 -1
  58. package/src/core/registry-check.ts +148 -6
  59. package/src/core/renderers/manifest-writer.ts +33 -0
  60. package/src/core/rule-text.ts +97 -0
  61. package/src/core/services/file-classifier.ts +10 -3
  62. package/src/core/services/verifier.ts +14 -3
  63. package/src/core/storage/paths.ts +6 -5
  64. package/src/core/storage/types.ts +6 -0
  65. package/src/core/storage/work-area.ts +12 -0
  66. package/src/core/types.ts +37 -1
  67. package/src/core/verify.ts +17 -5
  68. package/src/harness/README.md +15 -0
  69. package/src/harness/agent/agent-context.ts +33 -0
  70. package/src/harness/agent/agent-errors.ts +30 -0
  71. package/src/harness/agent/agent-factory.ts +45 -0
  72. package/src/harness/agent/agent-reporter.ts +34 -0
  73. package/src/harness/agent/agent-result.ts +16 -0
  74. package/src/harness/agent/agent-runner.ts +32 -0
  75. package/src/harness/agent/agent-scheduler.ts +37 -0
  76. package/src/harness/agent/review-agent.ts +391 -0
  77. package/src/harness/config/harness-config.ts +84 -0
  78. package/src/harness/hunt/hunt-harness.ts +83 -0
  79. package/src/harness/hunt/hunt-options.ts +75 -0
  80. package/src/harness/hunt/hunt-result.ts +17 -0
  81. package/src/harness/hunt/hunt-session.ts +372 -0
  82. package/src/harness/hunt/review-planner.ts +98 -0
  83. package/src/harness/index.ts +36 -0
  84. package/src/harness/investigation/agent-state.ts +134 -0
  85. package/src/harness/investigation/evidence-verifier.ts +129 -0
  86. package/src/harness/investigation/evidence.ts +110 -0
  87. package/src/harness/investigation/finding-critic.ts +96 -0
  88. package/src/harness/investigation/investigation-planner.ts +118 -0
  89. package/src/harness/investigation/investigation-task.ts +10 -0
  90. package/src/harness/investigation/review-journal.ts +87 -0
  91. package/src/harness/investigation/rule-analyzer.ts +178 -0
  92. package/src/harness/investigation/rule-signals.ts +110 -0
  93. package/src/harness/prompts/agent-schemas.ts +57 -0
  94. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  95. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  96. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  97. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  98. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  99. package/src/harness/tools/agent-tool.ts +102 -0
  100. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  101. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  102. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  103. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  104. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  105. package/src/harness/tools/builtin/helpers.ts +115 -0
  106. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  107. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  108. package/src/harness/tools/builtin/index.ts +59 -0
  109. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  110. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  111. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  112. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  113. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  114. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  115. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  116. package/src/harness/tools/path-guard.ts +90 -0
  117. package/src/harness/tools/result-limiter.ts +49 -0
  118. package/src/harness/tools/secret-redactor.ts +54 -0
  119. package/src/harness/tools/tool-cache.ts +50 -0
  120. package/src/harness/tools/tool-call-budget.ts +60 -0
  121. package/src/harness/tools/tool-context.ts +5 -0
  122. package/src/harness/tools/tool-middleware.ts +105 -0
  123. package/src/harness/tools/tool-policy.ts +51 -0
  124. package/src/harness/tools/tool-registry.ts +112 -0
  125. package/src/harness/tools/tool-result.ts +15 -0
  126. package/src/harness/tools/toolbox.ts +36 -0
  127. package/src/harness/validation/finding-validator.ts +119 -0
  128. package/src/index.ts +46 -0
  129. package/src/server/client/utils/constants.ts +27 -5
  130. package/src/server/client/utils/types.ts +6 -0
  131. package/src/server/index.ts +3 -2
  132. package/src/skill/OUTPUT-FORMAT.md +12 -0
  133. package/src/skill/SETUP.md +6 -2
  134. package/src/skill/hunt.md +14 -2
  135. package/src/skill/verify.md +1 -1
  136. package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
  137. package/src/tests/ai-core.test.ts +120 -0
  138. package/src/tests/ai-features.test.ts +183 -0
  139. package/src/tests/ast.test.ts +335 -0
  140. package/src/tests/env.test.ts +69 -0
  141. package/src/tests/fallback.test.ts +172 -0
  142. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  143. package/src/tests/fixtures/fake-investigation.ts +64 -0
  144. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  145. package/src/tests/harness-agent.test.ts +122 -0
  146. package/src/tests/harness-investigation.test.ts +206 -0
  147. package/src/tests/harness-tools.test.ts +148 -0
  148. package/src/tests/hunt-command.test.ts +212 -0
  149. package/src/tests/logger.test.ts +83 -0
  150. package/src/tests/review-scope.test.ts +11 -11
  151. package/src/tests/rule-analyzer.test.ts +61 -0
  152. package/src/tests/tool-contracts.test.ts +82 -0
  153. package/src/tests/verify.test.ts +1 -1
@@ -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
+ });
@@ -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
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tools fake para a suíte do harness.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type {
7
+ AgentTool,
8
+ ToolContext,
9
+ ToolMetadata,
10
+ ToolResult,
11
+ } from "../../harness/tools/agent-tool";
12
+
13
+ /** Tool de eco (testa registry/middleware). */
14
+ export class EchoTool implements AgentTool<{ value: string }, { value: string }> {
15
+ readonly metadata: ToolMetadata = {
16
+ name: "echo",
17
+ description: "Devolve o valor recebido.",
18
+ category: "context",
19
+ capabilities: ["read"],
20
+ readOnly: true,
21
+ cost: 1,
22
+ cacheTtlMs: 60_000,
23
+ };
24
+
25
+ readonly inputSchema = z.object({ value: z.string() });
26
+
27
+ async execute(
28
+ input: { value: string },
29
+ _context: ToolContext,
30
+ ): Promise<ToolResult<{ value: string }>> {
31
+ return { ok: true, data: { value: input.value } };
32
+ }
33
+ }
34
+
35
+ /** Tool que produz evidência para o estado. */
36
+ export class EvidenceTool implements AgentTool<{ file: string }, unknown> {
37
+ readonly metadata: ToolMetadata = {
38
+ name: "evidence",
39
+ description: "Produz uma evidência fake.",
40
+ category: "evidence",
41
+ capabilities: ["read"],
42
+ readOnly: true,
43
+ cost: 1,
44
+ };
45
+
46
+ readonly inputSchema = z.object({ file: z.string() });
47
+
48
+ async execute(
49
+ input: { file: string },
50
+ _context: ToolContext,
51
+ ): Promise<ToolResult<unknown>> {
52
+ return {
53
+ ok: true,
54
+ data: { file: input.file },
55
+ evidence: [
56
+ {
57
+ id: "",
58
+ file: input.file,
59
+ startLine: 1,
60
+ endLine: 1,
61
+ snippet: "const x: any = 1;",
62
+ kind: "definition",
63
+ sourceTool: "evidence",
64
+ confidence: 0.9,
65
+ },
66
+ ],
67
+ };
68
+ }
69
+ }