@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.
Files changed (196) hide show
  1. package/README.md +35 -41
  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/commands/status-command.ts +46 -0
  11. package/src/cli/help.ts +18 -1
  12. package/src/cli/index.ts +13 -6
  13. package/src/core/ai/ai-client.ts +221 -0
  14. package/src/core/ai/ai-sdk-model-provider.ts +101 -0
  15. package/src/core/ai/errors.ts +36 -0
  16. package/src/core/ai/features/brief-enricher.ts +62 -0
  17. package/src/core/ai/features/classifier.ts +50 -0
  18. package/src/core/ai/features/context-summarizer.ts +31 -0
  19. package/src/core/ai/features/pre-analyzer.ts +51 -0
  20. package/src/core/ai/features/verifier.ts +45 -0
  21. package/src/core/ai/index.ts +56 -0
  22. package/src/core/ai/model-factory.ts +137 -0
  23. package/src/core/ai/prompts/base.ts +36 -0
  24. package/src/core/ai/prompts/brief.ts +87 -0
  25. package/src/core/ai/prompts/classification.ts +36 -0
  26. package/src/core/ai/prompts/context.ts +29 -0
  27. package/src/core/ai/prompts/pre-analysis.ts +49 -0
  28. package/src/core/ai/prompts/verify.ts +40 -0
  29. package/src/core/ai/schemas.ts +110 -0
  30. package/src/core/ai/types.ts +76 -0
  31. package/src/core/ai/usage-budget.ts +75 -0
  32. package/src/core/ai/usage-recorder.ts +79 -0
  33. package/src/core/assets.ts +16 -0
  34. package/src/core/ast/diff.ts +220 -0
  35. package/src/core/ast/impact.ts +127 -0
  36. package/src/core/ast/parser.ts +167 -0
  37. package/src/core/ast/service.ts +233 -0
  38. package/src/core/ast/symbols.ts +294 -0
  39. package/src/core/ast/types.ts +125 -0
  40. package/src/core/brief.ts +138 -0
  41. package/src/core/commands/findings.ts +120 -6
  42. package/src/core/commands/review.ts +2 -0
  43. package/src/core/commands/scaffold.ts +159 -10
  44. package/src/core/commands/status.ts +63 -0
  45. package/src/core/commands/update.ts +5 -3
  46. package/src/core/config.ts +85 -0
  47. package/src/core/defaults.ts +27 -0
  48. package/src/core/domain/watchdog.ts +43 -5
  49. package/src/core/engine/houndsman-engine.ts +16 -1
  50. package/src/core/engine/review-scope.ts +77 -13
  51. package/src/core/engine/review-session.ts +27 -2
  52. package/src/core/env.ts +101 -0
  53. package/src/core/file-classifier.ts +25 -4
  54. package/src/core/findings-state.ts +21 -4
  55. package/src/core/github-review.ts +620 -57
  56. package/src/core/jev/jev-client.ts +8 -7
  57. package/src/core/jev/jev-verify.ts +2 -1
  58. package/src/core/logging/index.ts +17 -0
  59. package/src/core/logging/logger.ts +187 -0
  60. package/src/core/orchestrator.ts +454 -16
  61. package/src/core/paths.ts +3 -0
  62. package/src/core/project-config.ts +123 -1
  63. package/src/core/registry-check.ts +148 -6
  64. package/src/core/renderers/manifest-writer.ts +33 -0
  65. package/src/core/renderers/resume-renderer.ts +151 -0
  66. package/src/core/report-builder.ts +7 -4
  67. package/src/core/rule-text.ts +97 -0
  68. package/src/core/run-status.ts +39 -5
  69. package/src/core/services/file-classifier.ts +10 -3
  70. package/src/core/services/verifier.ts +14 -3
  71. package/src/core/storage/file/file-storage.ts +2 -0
  72. package/src/core/storage/paths.ts +6 -5
  73. package/src/core/storage/sqlite/mappers.ts +2 -0
  74. package/src/core/storage/sqlite/schema.ts +9 -1
  75. package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
  76. package/src/core/storage/types.ts +15 -0
  77. package/src/core/storage/work-area.ts +12 -0
  78. package/src/core/types.ts +37 -1
  79. package/src/core/verify.ts +17 -5
  80. package/src/harness/README.md +15 -0
  81. package/src/harness/agent/agent-context.ts +33 -0
  82. package/src/harness/agent/agent-errors.ts +30 -0
  83. package/src/harness/agent/agent-factory.ts +45 -0
  84. package/src/harness/agent/agent-reporter.ts +34 -0
  85. package/src/harness/agent/agent-result.ts +16 -0
  86. package/src/harness/agent/agent-runner.ts +32 -0
  87. package/src/harness/agent/agent-scheduler.ts +37 -0
  88. package/src/harness/agent/review-agent.ts +391 -0
  89. package/src/harness/config/harness-config.ts +84 -0
  90. package/src/harness/hunt/hunt-harness.ts +83 -0
  91. package/src/harness/hunt/hunt-options.ts +75 -0
  92. package/src/harness/hunt/hunt-result.ts +17 -0
  93. package/src/harness/hunt/hunt-session.ts +372 -0
  94. package/src/harness/hunt/review-planner.ts +98 -0
  95. package/src/harness/index.ts +36 -0
  96. package/src/harness/investigation/agent-state.ts +134 -0
  97. package/src/harness/investigation/evidence-verifier.ts +129 -0
  98. package/src/harness/investigation/evidence.ts +110 -0
  99. package/src/harness/investigation/finding-critic.ts +96 -0
  100. package/src/harness/investigation/investigation-planner.ts +118 -0
  101. package/src/harness/investigation/investigation-task.ts +10 -0
  102. package/src/harness/investigation/review-journal.ts +87 -0
  103. package/src/harness/investigation/rule-analyzer.ts +178 -0
  104. package/src/harness/investigation/rule-signals.ts +110 -0
  105. package/src/harness/prompts/agent-schemas.ts +57 -0
  106. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  107. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  108. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  109. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  110. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  111. package/src/harness/tools/agent-tool.ts +102 -0
  112. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  113. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  114. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  115. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  116. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  117. package/src/harness/tools/builtin/helpers.ts +115 -0
  118. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  119. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  120. package/src/harness/tools/builtin/index.ts +59 -0
  121. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  122. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  123. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  124. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  125. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  126. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  127. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  128. package/src/harness/tools/path-guard.ts +90 -0
  129. package/src/harness/tools/result-limiter.ts +49 -0
  130. package/src/harness/tools/secret-redactor.ts +54 -0
  131. package/src/harness/tools/tool-cache.ts +50 -0
  132. package/src/harness/tools/tool-call-budget.ts +60 -0
  133. package/src/harness/tools/tool-context.ts +5 -0
  134. package/src/harness/tools/tool-middleware.ts +105 -0
  135. package/src/harness/tools/tool-policy.ts +51 -0
  136. package/src/harness/tools/tool-registry.ts +112 -0
  137. package/src/harness/tools/tool-result.ts +15 -0
  138. package/src/harness/tools/toolbox.ts +36 -0
  139. package/src/harness/validation/finding-validator.ts +119 -0
  140. package/src/index.ts +46 -0
  141. package/src/server/client/App.tsx +26 -9
  142. package/src/server/client/ArtifactsPanel.tsx +12 -5
  143. package/src/server/client/AutoRefresh.tsx +1 -1
  144. package/src/server/client/Filters.tsx +7 -2
  145. package/src/server/client/FindingCard.tsx +14 -10
  146. package/src/server/client/FindingsList.tsx +2 -2
  147. package/src/server/client/MarkdownText.tsx +92 -12
  148. package/src/server/client/ResumePanel.tsx +368 -0
  149. package/src/server/client/RunList.tsx +3 -3
  150. package/src/server/client/SubmitModal.tsx +49 -6
  151. package/src/server/client/SummaryBar.tsx +1 -1
  152. package/src/server/client/icons.tsx +17 -0
  153. package/src/server/client/{constants.ts → utils/constants.ts} +61 -10
  154. package/src/server/client/utils/markdown.ts +319 -0
  155. package/src/server/client/{types.ts → utils/types.ts} +64 -0
  156. package/src/server/index.ts +66 -7
  157. package/src/server/run-repository.ts +9 -0
  158. package/src/skill/OUTPUT-FORMAT.md +12 -0
  159. package/src/skill/SETUP.md +6 -2
  160. package/src/skill/example-review.md +22 -14
  161. package/src/skill/hunt.md +45 -19
  162. package/src/skill/verify.md +3 -2
  163. package/src/tests/__snapshots__/baseline.test.ts.snap +126 -42
  164. package/src/tests/ai-core.test.ts +120 -0
  165. package/src/tests/ai-features.test.ts +183 -0
  166. package/src/tests/ast.test.ts +335 -0
  167. package/src/tests/baseline.test.ts +3 -0
  168. package/src/tests/code-highlight.test.ts +1 -1
  169. package/src/tests/env.test.ts +69 -0
  170. package/src/tests/fallback.test.ts +172 -0
  171. package/src/tests/findings-state.test.ts +50 -2
  172. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  173. package/src/tests/fixtures/fake-investigation.ts +64 -0
  174. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  175. package/src/tests/github-review.test.ts +409 -60
  176. package/src/tests/harness-agent.test.ts +122 -0
  177. package/src/tests/harness-investigation.test.ts +206 -0
  178. package/src/tests/harness-tools.test.ts +148 -0
  179. package/src/tests/hunt-command.test.ts +212 -0
  180. package/src/tests/logger.test.ts +83 -0
  181. package/src/tests/markdown.test.ts +61 -1
  182. package/src/tests/review-resume.test.ts +101 -0
  183. package/src/tests/review-scope.test.ts +302 -0
  184. package/src/tests/review-scripts.test.ts +73 -0
  185. package/src/tests/rule-analyzer.test.ts +61 -0
  186. package/src/tests/run-status-view.test.ts +94 -0
  187. package/src/tests/run-status.test.ts +53 -0
  188. package/src/tests/server.test.ts +76 -21
  189. package/src/tests/storage.test.ts +63 -0
  190. package/src/tests/tool-contracts.test.ts +82 -0
  191. package/src/tests/verify.test.ts +1 -1
  192. package/src/server/client/markdown.ts +0 -153
  193. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  194. /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
  195. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
  196. /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Status da run (biblioteca) — leitura e heartbeat para a UI.
3
+ *
4
+ * `status ping` é o sinal de vida de uma skill em execução (revisão ou
5
+ * correção). O heartbeat é separado de `updatedAt`: triagem/sync atualizam o
6
+ * segundo, mas não fingem que existe uma execução em andamento.
7
+ */
8
+
9
+ import { currentBranch } from "../pr-context";
10
+ import { type RunStatus, touchRunStatus } from "../run-status";
11
+ import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
12
+
13
+ export type StatusResult = {
14
+ branch: string;
15
+ status: RunStatus | null;
16
+ startedAt?: string;
17
+ heartbeatAt?: string;
18
+ updatedAt: string;
19
+ };
20
+
21
+ function toResult(review: ReviewRecord): StatusResult {
22
+ return {
23
+ branch: review.branch,
24
+ status: review.status,
25
+ startedAt: review.startedAt,
26
+ heartbeatAt: review.heartbeatAt,
27
+ updatedAt: review.updatedAt,
28
+ };
29
+ }
30
+
31
+ /** Resolve a review da branch (explícita ou a atual do git). */
32
+ function findReview(
33
+ storage: StorageProvider,
34
+ repo: RepoIdentity,
35
+ branch?: string,
36
+ ): ReviewRecord {
37
+ const target = branch ?? currentBranch();
38
+ const review = storage.findReview(repo.key, target);
39
+ if (!review) {
40
+ throw new Error(`Nenhuma revisão para '${target}'.`);
41
+ }
42
+ return review;
43
+ }
44
+
45
+ /** Lê o status atual da run. */
46
+ export function readStatus(
47
+ storage: StorageProvider,
48
+ repo: RepoIdentity,
49
+ branch?: string,
50
+ ): StatusResult {
51
+ return toResult(findReview(storage, repo, branch));
52
+ }
53
+
54
+ /** Renova o heartbeat de uma execução ativa (`reviewing`/`fixing`). */
55
+ export function pingStatus(
56
+ storage: StorageProvider,
57
+ repo: RepoIdentity,
58
+ branch?: string,
59
+ now?: string,
60
+ ): StatusResult {
61
+ const review = findReview(storage, repo, branch);
62
+ return toResult(touchRunStatus(storage, review.id, now));
63
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Subcomando `update` — sincroniza os arquivos **auto-gerados** da camada `.houndsman/`.
3
3
  *
4
- * Reescreve `core/types.ts`, `core/skill/*.md`, `.houndsman/.gitignore` e os
5
- * ponteiros de skill dos harnesses. Nunca toca nos arquivos do usuário
6
- * (`watchdogs/`, `doc-mappings.ts`, `exclusions.ts`, `houndsman.config.ts`).
4
+ * Reescreve `core/types.ts`, `core/skill/*.md`, `core/grammars/*.wasm`,
5
+ * `.houndsman/.gitignore` e os ponteiros de skill dos harnesses. Nunca toca nos
6
+ * arquivos do usuário (`watchdogs/`, `doc-mappings.ts`, `exclusions.ts`,
7
+ * `houndsman.config.ts`). As grammars do AST são sincronizadas aqui — o
8
+ * consumidor não precisa rodar nenhum comando extra.
7
9
  */
8
10
 
9
11
  import { createScaffoldContext, syncGeneratedFiles } from "./scaffold";
@@ -6,11 +6,21 @@
6
6
  * (camada `houndsman/`) — único ponto de troca ao portar para outro repositório.
7
7
  */
8
8
 
9
+ import type { AstLanguage } from "./ast/types";
9
10
  import { ProjectConfig } from "./project-config";
10
11
  import type { WatchdogDefinition } from "./types";
11
12
 
12
13
  export {
14
+ DEFAULT_AI_CONCURRENCY,
15
+ DEFAULT_AI_MAX_CALLS_PER_REVIEW,
16
+ DEFAULT_AI_MAX_OUTPUT_TOKENS,
17
+ DEFAULT_AI_MAX_STEPS,
18
+ DEFAULT_AI_MODEL,
19
+ DEFAULT_AI_PROVIDER,
20
+ DEFAULT_AI_TIMEOUT_MS,
13
21
  DEFAULT_ALWAYS_INCLUDE_DOCS,
22
+ DEFAULT_AST_MAX_FILE_BYTES,
23
+ DEFAULT_AST_MAX_IMPACT_FILES,
14
24
  DEFAULT_JEV_CONCURRENCY,
15
25
  DEFAULT_MAX_FILES_PER_BRIEF,
16
26
  } from "./defaults";
@@ -21,12 +31,83 @@ export type HoundsmanBudgets = {
21
31
  maxFilesPerBrief?: number;
22
32
  };
23
33
 
34
+ /** Configuração do subsistema AST (determinístico, sem LLM). */
35
+ export type HoundsmanAst = {
36
+ /**
37
+ * Liga/desliga o AST (default: `true`). Com grammars ausentes ou ABI
38
+ * incompatível, o motor segue sem AST (fail-open).
39
+ */
40
+ enabled?: boolean;
41
+ /** Teto de bytes por arquivo (default: 512_000). */
42
+ maxFileBytes?: number;
43
+ /** Teto de arquivos candidatos na análise de impacto (default: 200). */
44
+ maxImpactFiles?: number;
45
+ /** Override de extensão → linguagem (`".vue": "typescript"`). */
46
+ languages?: Record<string, AstLanguage>;
47
+ /** Aliases de import (`"@/": "src/"`). */
48
+ aliases?: Record<string, string>;
49
+ };
50
+
24
51
  /** Concorrência das fases paralelas internas. */
25
52
  export type HoundsmanConcurrency = {
26
53
  /** Chamadas Jev simultâneas (default: 5). */
27
54
  jev?: number;
28
55
  };
29
56
 
57
+ /** Providers suportados pela camada de IA. */
58
+ export type AiProviderKind = "deepseek" | "openai" | "anthropic";
59
+
60
+ /** Features de IA do fluxo determinístico (todas fail-open). */
61
+ export type HoundsmanAiFeatures = {
62
+ /** Resume o contexto da PR (`context-summary`) quando o subagente não gerou. */
63
+ context?: boolean;
64
+ /** Enriquece os briefs com um plano de revisão sugerido. */
65
+ brief?: boolean;
66
+ /** Classifica arquivos que nenhum watchdog casou. */
67
+ classification?: boolean;
68
+ /** Gera candidatos de pré-análise (substitui o Jev quando disponível). */
69
+ preAnalysis?: boolean;
70
+ /** Confirma/refuta findings ambíguos no `verify`. */
71
+ verify?: boolean;
72
+ };
73
+
74
+ /** Overrides de IA por watchdog. */
75
+ export type HoundsmanAiWatchdog = {
76
+ model?: string;
77
+ maxSteps?: number;
78
+ maxOutputTokens?: number;
79
+ };
80
+
81
+ /** Configuração da camada de IA (genérica; o harness nativo também a consome). */
82
+ export type HoundsmanAi = {
83
+ /** Liga a IA do fluxo normal (default: `true`; sem chave é fail-open). */
84
+ enabled?: boolean;
85
+ /** Provider de modelo (default: `deepseek`). */
86
+ provider?: AiProviderKind;
87
+ /** Modelo (default em `defaults.ts`). */
88
+ model?: string;
89
+ /** Base URL customizada do provider. */
90
+ baseURL?: string;
91
+ /** Chamadas simultâneas (default: 2). */
92
+ concurrency?: number;
93
+ /** Passos por agente (default: 12). */
94
+ maxSteps?: number;
95
+ /** Teto de tokens de saída por chamada. */
96
+ maxOutputTokens?: number;
97
+ /** Temperatura (default: 0). */
98
+ temperature?: number;
99
+ /** Timeout por chamada em ms (default: 120_000). */
100
+ timeoutMs?: number;
101
+ /** Teto de chamadas por review (default: 40). */
102
+ maxCallsPerReview?: number;
103
+ /** Teto de tokens por review. */
104
+ maxTokensPerReview?: number;
105
+ /** Features do fluxo normal. */
106
+ features?: HoundsmanAiFeatures;
107
+ /** Overrides por watchdog. */
108
+ watchdogs?: Record<string, HoundsmanAiWatchdog>;
109
+ };
110
+
30
111
  /** Backend de persistência (reviews, findings e artefatos). */
31
112
  export type HoundsmanStorage = {
32
113
  /**
@@ -68,6 +149,10 @@ export type HoundsmanConfig<TId extends string = string> = {
68
149
  budgets?: HoundsmanBudgets;
69
150
  /** Concorrência das fases paralelas internas. */
70
151
  concurrency?: HoundsmanConcurrency;
152
+ /** AST determinístico (classificação estrutural, brief e impacto). */
153
+ ast?: HoundsmanAst;
154
+ /** Camada de IA (briefs, contexto, classificação e o harness nativo). */
155
+ ai?: HoundsmanAi;
71
156
  };
72
157
 
73
158
  /** Entrada de `defineHoundsmanConfig` — `watchdogIds` é derivado, não informado. */
@@ -18,3 +18,30 @@ export const DEFAULT_MAX_FILES_PER_BRIEF = 40;
18
18
 
19
19
  /** Concorrência default das chamadas simultâneas ao Jev. */
20
20
  export const DEFAULT_JEV_CONCURRENCY = 5;
21
+
22
+ /** Teto default de bytes por arquivo no AST (evita bundles/minificados). */
23
+ export const DEFAULT_AST_MAX_FILE_BYTES = 512_000;
24
+
25
+ /** Teto default de arquivos candidatos na análise de impacto. */
26
+ export const DEFAULT_AST_MAX_IMPACT_FILES = 200;
27
+
28
+ /** Provider default da camada de IA (DeepSeek). */
29
+ export const DEFAULT_AI_PROVIDER = "deepseek" as const;
30
+
31
+ /** Modelo default da camada de IA. */
32
+ export const DEFAULT_AI_MODEL = "deepseek-v4.1-flash";
33
+
34
+ /** Concorrência default das chamadas de IA. */
35
+ export const DEFAULT_AI_CONCURRENCY = 2;
36
+
37
+ /** Passos default por agente (loop de tools + saída). */
38
+ export const DEFAULT_AI_MAX_STEPS = 12;
39
+
40
+ /** Timeout default de uma chamada de modelo (ms). */
41
+ export const DEFAULT_AI_TIMEOUT_MS = 120_000;
42
+
43
+ /** Teto default de chamadas de IA por review. */
44
+ export const DEFAULT_AI_MAX_CALLS_PER_REVIEW = 40;
45
+
46
+ /** Teto default de tokens de saída por chamada. */
47
+ export const DEFAULT_AI_MAX_OUTPUT_TOKENS = 4096;
@@ -6,6 +6,7 @@
6
6
  * render do brief) sem alterar o contrato do consumidor.
7
7
  */
8
8
 
9
+ import type { AstFacts } from "../ast/types";
9
10
  import { renderBrief } from "../brief";
10
11
  import {
11
12
  type ClassifyRule,
@@ -18,16 +19,51 @@ import {
18
19
  /** Tipo assumido quando `type` é omitido. */
19
20
  const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
20
21
 
22
+ /** `true` se algum valor do conjunto casa o padrão. */
23
+ function matchesAny(values: readonly string[], pattern: RegExp): boolean {
24
+ return values.some((value) => pattern.test(value));
25
+ }
26
+
21
27
  /** `true` se o arquivo casa a entrada de classificação. */
22
28
  export function matchesClassifyRule(
23
29
  filePath: string,
24
30
  rule: ClassifyRule,
25
31
  content: string | undefined,
32
+ facts?: AstFacts,
26
33
  ): boolean {
27
- if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
28
- return content !== undefined && new RegExp(rule.value).test(content);
34
+ const pattern = new RegExp(rule.value);
35
+ switch (rule.type ?? DEFAULT_CLASSIFY_TYPE) {
36
+ case "content":
37
+ return content !== undefined && pattern.test(content);
38
+ case "symbol":
39
+ return (
40
+ facts !== undefined &&
41
+ matchesAny(
42
+ facts.symbols.flatMap((symbol) => [symbol.name, symbol.kind]),
43
+ pattern,
44
+ )
45
+ );
46
+ case "import":
47
+ return (
48
+ facts !== undefined &&
49
+ matchesAny(
50
+ facts.imports.flatMap((item) => [item.source, ...item.names]),
51
+ pattern,
52
+ )
53
+ );
54
+ case "export":
55
+ return (
56
+ facts !== undefined &&
57
+ matchesAny(
58
+ facts.exports.map((item) => item.name),
59
+ pattern,
60
+ )
61
+ );
62
+ case "decorator":
63
+ return facts !== undefined && matchesAny(facts.decorators, pattern);
64
+ default:
65
+ return pattern.test(filePath);
29
66
  }
30
- return new RegExp(rule.value).test(filePath);
31
67
  }
32
68
 
33
69
  /** Contexto "essencial" do watchdog — SEM regras (docs/UI/inspeção). */
@@ -71,8 +107,10 @@ export class Watchdog {
71
107
  }
72
108
 
73
109
  /** `true` se o arquivo é deste domínio. */
74
- matches(filePath: string, content?: string): boolean {
75
- return this.classify.some((rule) => matchesClassifyRule(filePath, rule, content));
110
+ matches(filePath: string, content?: string, facts?: AstFacts): boolean {
111
+ return this.classify.some((rule) =>
112
+ matchesClassifyRule(filePath, rule, content, facts),
113
+ );
76
114
  }
77
115
 
78
116
  /** Regras ordenadas por severidade (mais grave primeiro). */
@@ -6,6 +6,8 @@
6
6
  * de instanciar/importar tudo diretamente.
7
7
  */
8
8
 
9
+ import { AiClient } from "../ai/ai-client";
10
+ import { AstService } from "../ast/service";
9
11
  import type { HoundsmanConfig } from "../config";
10
12
  import type { ReviewOptions } from "../orchestrator";
11
13
  import { ProjectConfig } from "../project-config";
@@ -26,6 +28,8 @@ export type EngineDeps = {
26
28
  fixBatch?: FixBatchBuilder;
27
29
  storage?: StorageProvider;
28
30
  repo?: RepoIdentity;
31
+ ast?: AstService;
32
+ ai?: AiClient;
29
33
  };
30
34
 
31
35
  /** Composition root do motor. */
@@ -37,6 +41,8 @@ export class HoundsmanEngine {
37
41
  readonly fixBatch: FixBatchBuilder;
38
42
  readonly storage: StorageProvider;
39
43
  readonly repo: RepoIdentity;
44
+ readonly ast: AstService;
45
+ readonly ai: AiClient;
40
46
 
41
47
  constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
42
48
  this.project = new ProjectConfig(raw);
@@ -46,6 +52,8 @@ export class HoundsmanEngine {
46
52
  this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
47
53
  this.storage = deps.storage ?? createStorage(raw);
48
54
  this.repo = deps.repo ?? resolveRepoIdentity(this.git);
55
+ this.ast = deps.ast ?? new AstService(raw, this.git);
56
+ this.ai = deps.ai ?? new AiClient({ project: this.project });
49
57
  }
50
58
 
51
59
  /** Configuração crua (contrato do consumidor). */
@@ -55,7 +63,14 @@ export class HoundsmanEngine {
55
63
 
56
64
  /** Abre uma sessão de revisão (prepare/finalize). */
57
65
  session(options: ReviewOptions): ReviewSession {
58
- return ReviewSession.from(this.config, options, this.storage, this.repo);
66
+ return ReviewSession.from(
67
+ this.config,
68
+ options,
69
+ this.storage,
70
+ this.repo,
71
+ this.ast,
72
+ this.ai,
73
+ );
59
74
  }
60
75
 
61
76
  /** Libera o provider (fecha o SQLite e faz checkpoint do WAL). */
@@ -5,11 +5,14 @@
5
5
  * carrega os findings de arquivos inalterados (carry-over).
6
6
  */
7
7
 
8
+ import type { AstService } from "../ast/service";
9
+ import type { AstAnalysis, AstFacts } from "../ast/types";
8
10
  import type { HoundsmanConfig } from "../config";
9
11
  import { churnToRecord, collectDiff, type DiffSource } from "../diff-source";
10
- import type { ClassificationResult } from "../file-classifier";
12
+ import { type ClassificationResult, parseEntries } from "../file-classifier";
11
13
  import type { FindingsStateFile } from "../findings-state";
12
14
  import { collectDiffSince, diffEntries, isAncestor } from "../git-revision";
15
+ import { runGit, tryGit } from "../git-runner";
13
16
  import type { MergedFinding } from "../report-builder";
14
17
  import { FileClassifier } from "../services/file-classifier";
15
18
  import { buildCarryover } from "./carryover";
@@ -34,42 +37,80 @@ export type RunClassification = ClassificationResult & {
34
37
  carryover?: MergedFinding[];
35
38
  };
36
39
 
37
- /** Resultado da resolução de escopo. */
38
- export type ReviewScope = {
39
- classification: RunClassification;
40
- diff: DiffSource;
41
- reviewedFrom?: string;
42
- };
40
+ /** Resultado da resolução de escopo: revisar ou nada a fazer. */
41
+ export type ReviewScope =
42
+ | {
43
+ /** O `HEAD` é o mesmo commit já revisado — não há o que re-revisar. */
44
+ kind: "noop";
45
+ headSha: string;
46
+ /** Commit já coberto pelo review anterior. */
47
+ reviewedFrom: string;
48
+ }
49
+ | {
50
+ kind: "review";
51
+ classification: RunClassification;
52
+ diff: DiffSource;
53
+ reviewedFrom?: string;
54
+ /** Fatos/delta por arquivo alterado (quando o AST está habilitado). */
55
+ ast?: Map<string, AstAnalysis>;
56
+ };
43
57
 
44
58
  /** Decide entre review completo e incremental. */
45
59
  export class ReviewScopeResolver {
46
60
  private readonly options: ScopeOptions;
47
61
  private readonly classifier: FileClassifier;
62
+ private readonly ast?: AstService;
48
63
 
49
- constructor(options: ScopeOptions, config: HoundsmanConfig) {
64
+ constructor(options: ScopeOptions, config: HoundsmanConfig, ast?: AstService) {
50
65
  this.options = options;
51
66
  this.classifier = new FileClassifier(config);
67
+ this.ast = ast;
52
68
  }
53
69
 
54
70
  /**
71
+ * No-op quando o `HEAD` é exatamente o commit já revisado (sem
72
+ * `--full`/`--clean`): re-revisar a PR inteira seria desperdício, e o
73
+ * relatório anterior continua válido.
74
+ *
55
75
  * Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
56
76
  * sucede (sem rebase) e não veio `--full`/`--clean`.
57
77
  */
58
- resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
78
+ async resolve(
79
+ previous: FindingsStateFile | null,
80
+ headSha: string,
81
+ ): Promise<ReviewScope> {
59
82
  const prevSha = previous?.reviewed?.headSha;
83
+
84
+ if (!this.options.full && !this.options.clean && prevSha === headSha) {
85
+ return { kind: "noop", headSha, reviewedFrom: headSha };
86
+ }
87
+
60
88
  if (
61
89
  this.options.full ||
62
90
  this.options.clean ||
63
91
  !prevSha ||
64
- prevSha === headSha ||
65
92
  !isAncestor(prevSha, headSha)
66
93
  ) {
67
94
  const diff = collectDiff(this.options.base, this.options.head);
95
+ const entries = parseEntries(
96
+ runGit([
97
+ "diff",
98
+ `${this.options.base}...${this.options.head}`,
99
+ "--name-status",
100
+ ]),
101
+ );
102
+ const baseRef =
103
+ mergeBase(this.options.base, this.options.head) ?? this.options.base;
104
+ const ast = this.ast
105
+ ? await this.ast.analyze(entries, diff, baseRef)
106
+ : undefined;
68
107
  return {
108
+ kind: "review",
69
109
  diff,
110
+ ast,
70
111
  classification: {
71
112
  ...this.classifier
72
- .classifyRange(this.options.base, this.options.head, diff)
113
+ .classify(entries, churnToRecord(diff.churn), factsOf(ast))
73
114
  .toJSON(),
74
115
  headSha,
75
116
  mode: "full",
@@ -81,8 +122,11 @@ export class ReviewScopeResolver {
81
122
  const diff = collectDiffSince(prevSha, headSha);
82
123
  const entries = diffEntries(range);
83
124
  const changedPaths = new Set(entries.map((entry) => entry.path));
125
+ const ast = this.ast ? await this.ast.analyze(entries, diff, prevSha) : undefined;
84
126
  const classification: RunClassification = {
85
- ...this.classifier.classify(entries, churnToRecord(diff.churn)).toJSON(),
127
+ ...this.classifier
128
+ .classify(entries, churnToRecord(diff.churn), factsOf(ast))
129
+ .toJSON(),
86
130
  base: prevSha,
87
131
  head: this.options.head,
88
132
  headSha,
@@ -90,6 +134,26 @@ export class ReviewScopeResolver {
90
134
  reviewedFrom: prevSha,
91
135
  carryover: buildCarryover(previous, changedPaths),
92
136
  };
93
- return { classification, diff, reviewedFrom: prevSha };
137
+ return { kind: "review", classification, diff, reviewedFrom: prevSha, ast };
94
138
  }
95
139
  }
140
+
141
+ /** Fatos apenas dos arquivos presentes (deletados não classificam). */
142
+ function factsOf(
143
+ analyses: ReadonlyMap<string, AstAnalysis> | undefined,
144
+ ): ReadonlyMap<string, AstFacts> | undefined {
145
+ if (!analyses) return undefined;
146
+ const facts = new Map<string, AstFacts>();
147
+ for (const [path, analysis] of analyses) {
148
+ if (!analysis.deleted) facts.set(path, analysis.facts);
149
+ }
150
+ return facts;
151
+ }
152
+
153
+ /** SHA do merge-base entre base e head (best effort). */
154
+ function mergeBase(base: string, head: string): string | null {
155
+ const result = tryGit(["merge-base", base, head]);
156
+ if (!result.ok) return null;
157
+ const sha = result.stdout.trim();
158
+ return sha.length > 0 ? sha : null;
159
+ }
@@ -4,6 +4,8 @@
4
4
  * É o ponto de entrada do `prepare`/`finalize` para o CLI e para o engine.
5
5
  */
6
6
 
7
+ import type { AiClient } from "../ai/ai-client";
8
+ import type { AstService } from "../ast/service";
7
9
  import type { HoundsmanConfig } from "../config";
8
10
  import {
9
11
  type FinalizeResult,
@@ -24,6 +26,8 @@ export class ReviewSession {
24
26
  readonly options: ReviewOptions;
25
27
  readonly branch: string;
26
28
  readonly context: ReviewContext;
29
+ private readonly ast?: AstService;
30
+ private readonly ai?: AiClient;
27
31
 
28
32
  constructor(
29
33
  config: HoundsmanConfig,
@@ -31,10 +35,14 @@ export class ReviewSession {
31
35
  branch: string,
32
36
  storage: StorageProvider,
33
37
  repo: RepoIdentity,
38
+ ast?: AstService,
39
+ ai?: AiClient,
34
40
  ) {
35
41
  this.config = config;
36
42
  this.options = options;
37
43
  this.branch = branch;
44
+ this.ast = ast;
45
+ this.ai = ai;
38
46
  const review = options.finalize
39
47
  ? storage.findReview(repo.key, branch)
40
48
  : storage.getOrCreateReview({
@@ -58,13 +66,30 @@ export class ReviewSession {
58
66
  options: ReviewOptions,
59
67
  storage: StorageProvider,
60
68
  repo: RepoIdentity,
69
+ ast?: AstService,
70
+ ai?: AiClient,
61
71
  ): ReviewSession {
62
- return new ReviewSession(config, options, resolveBranch(options), storage, repo);
72
+ return new ReviewSession(
73
+ config,
74
+ options,
75
+ resolveBranch(options),
76
+ storage,
77
+ repo,
78
+ ast,
79
+ ai,
80
+ );
63
81
  }
64
82
 
65
83
  /** Passo 1 — preparar. */
66
84
  prepare(): Promise<PrepareResult> {
67
- return prepare(this.options, this.branch, this.config, this.context);
85
+ return prepare(
86
+ this.options,
87
+ this.branch,
88
+ this.config,
89
+ this.context,
90
+ this.ast,
91
+ this.ai,
92
+ );
68
93
  }
69
94
 
70
95
  /** Passo 3 — finalizar. */
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Variáveis de ambiente do houndsman (fonte única).
3
+ *
4
+ * O Bun carrega `.env` do diretório atual automaticamente; o motor lê tudo
5
+ * daqui em vez de espalhar `process.env.*` pelo código. Chaves ausentes ou
6
+ * vazias viram `undefined` — o chamador decide se o recurso é opcional.
7
+ */
8
+
9
+ /**
10
+ * Nomes canônicos das variáveis.
11
+ *
12
+ * Toda chave de integração do houndsman usa o prefixo `HOUNDSMAN_` — evita
13
+ * colisão com envs do projeto consumidor. Apenas variáveis nativas do SO/CI
14
+ * (`LOCALAPPDATA`, `XDG_DATA_HOME`, `NO_COLOR`, `TERM`, `CI`) mantêm o nome
15
+ * global, porque são convenções do ambiente.
16
+ */
17
+ export const ENV_NAMES = {
18
+ typeSafeApiKey: "HOUNDSMAN_TYPE_SAFE_API_KEY",
19
+ deepSeekApiKey: "HOUNDSMAN_DEEP_SEEK_API_KEY",
20
+ openAiApiKey: "HOUNDSMAN_OPENAI_API_KEY",
21
+ anthropicApiKey: "HOUNDSMAN_ANTHROPIC_API_KEY",
22
+ home: "HOUNDSMAN_HOME",
23
+ localAppData: "LOCALAPPDATA",
24
+ xdgDataHome: "XDG_DATA_HOME",
25
+ noColor: "NO_COLOR",
26
+ term: "TERM",
27
+ ci: "CI",
28
+ } as const;
29
+
30
+ /** Snapshot tipado do ambiente (sem expor segredos). */
31
+ export type HoundsmanEnv = {
32
+ typeSafeApiKey?: string;
33
+ deepSeekApiKey?: string;
34
+ openAiApiKey?: string;
35
+ anthropicApiKey?: string;
36
+ home?: string;
37
+ localAppData?: string;
38
+ xdgDataHome?: string;
39
+ noColor?: string;
40
+ term?: string;
41
+ ci?: string;
42
+ };
43
+
44
+ /** Lê uma variável, apara espaços e trata vazio como ausente. */
45
+ function read(name: string): string | undefined {
46
+ const value = process.env[name]?.trim();
47
+ return value && value.length > 0 ? value : undefined;
48
+ }
49
+
50
+ /** Snapshot do ambiente no momento da chamada. */
51
+ export function readEnv(): HoundsmanEnv {
52
+ return {
53
+ typeSafeApiKey: read(ENV_NAMES.typeSafeApiKey),
54
+ deepSeekApiKey: read(ENV_NAMES.deepSeekApiKey),
55
+ openAiApiKey: read(ENV_NAMES.openAiApiKey),
56
+ anthropicApiKey: read(ENV_NAMES.anthropicApiKey),
57
+ home: read(ENV_NAMES.home),
58
+ localAppData: read(ENV_NAMES.localAppData),
59
+ xdgDataHome: read(ENV_NAMES.xdgDataHome),
60
+ noColor: read(ENV_NAMES.noColor),
61
+ term: read(ENV_NAMES.term),
62
+ ci: read(ENV_NAMES.ci),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Acesso sob demanda. Getter (em vez de constante congelada) mantém testes e
68
+ * processos que alteram o ambiente em runtime funcionando.
69
+ */
70
+ export const env = {
71
+ get typeSafeApiKey(): string | undefined {
72
+ return read(ENV_NAMES.typeSafeApiKey);
73
+ },
74
+ get deepSeekApiKey(): string | undefined {
75
+ return read(ENV_NAMES.deepSeekApiKey);
76
+ },
77
+ get openAiApiKey(): string | undefined {
78
+ return read(ENV_NAMES.openAiApiKey);
79
+ },
80
+ get anthropicApiKey(): string | undefined {
81
+ return read(ENV_NAMES.anthropicApiKey);
82
+ },
83
+ get home(): string | undefined {
84
+ return read(ENV_NAMES.home);
85
+ },
86
+ get localAppData(): string | undefined {
87
+ return read(ENV_NAMES.localAppData);
88
+ },
89
+ get xdgDataHome(): string | undefined {
90
+ return read(ENV_NAMES.xdgDataHome);
91
+ },
92
+ get noColor(): string | undefined {
93
+ return read(ENV_NAMES.noColor);
94
+ },
95
+ get term(): string | undefined {
96
+ return read(ENV_NAMES.term);
97
+ },
98
+ get ci(): string | undefined {
99
+ return read(ENV_NAMES.ci);
100
+ },
101
+ } as const;