@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
@@ -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";
@@ -48,16 +51,20 @@ export type ReviewScope =
48
51
  classification: RunClassification;
49
52
  diff: DiffSource;
50
53
  reviewedFrom?: string;
54
+ /** Fatos/delta por arquivo alterado (quando o AST está habilitado). */
55
+ ast?: Map<string, AstAnalysis>;
51
56
  };
52
57
 
53
58
  /** Decide entre review completo e incremental. */
54
59
  export class ReviewScopeResolver {
55
60
  private readonly options: ScopeOptions;
56
61
  private readonly classifier: FileClassifier;
62
+ private readonly ast?: AstService;
57
63
 
58
- constructor(options: ScopeOptions, config: HoundsmanConfig) {
64
+ constructor(options: ScopeOptions, config: HoundsmanConfig, ast?: AstService) {
59
65
  this.options = options;
60
66
  this.classifier = new FileClassifier(config);
67
+ this.ast = ast;
61
68
  }
62
69
 
63
70
  /**
@@ -68,7 +75,10 @@ export class ReviewScopeResolver {
68
75
  * Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
69
76
  * sucede (sem rebase) e não veio `--full`/`--clean`.
70
77
  */
71
- resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
78
+ async resolve(
79
+ previous: FindingsStateFile | null,
80
+ headSha: string,
81
+ ): Promise<ReviewScope> {
72
82
  const prevSha = previous?.reviewed?.headSha;
73
83
 
74
84
  if (!this.options.full && !this.options.clean && prevSha === headSha) {
@@ -82,12 +92,25 @@ export class ReviewScopeResolver {
82
92
  !isAncestor(prevSha, headSha)
83
93
  ) {
84
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;
85
107
  return {
86
108
  kind: "review",
87
109
  diff,
110
+ ast,
88
111
  classification: {
89
112
  ...this.classifier
90
- .classifyRange(this.options.base, this.options.head, diff)
113
+ .classify(entries, churnToRecord(diff.churn), factsOf(ast))
91
114
  .toJSON(),
92
115
  headSha,
93
116
  mode: "full",
@@ -99,8 +122,11 @@ export class ReviewScopeResolver {
99
122
  const diff = collectDiffSince(prevSha, headSha);
100
123
  const entries = diffEntries(range);
101
124
  const changedPaths = new Set(entries.map((entry) => entry.path));
125
+ const ast = this.ast ? await this.ast.analyze(entries, diff, prevSha) : undefined;
102
126
  const classification: RunClassification = {
103
- ...this.classifier.classify(entries, churnToRecord(diff.churn)).toJSON(),
127
+ ...this.classifier
128
+ .classify(entries, churnToRecord(diff.churn), factsOf(ast))
129
+ .toJSON(),
104
130
  base: prevSha,
105
131
  head: this.options.head,
106
132
  headSha,
@@ -108,6 +134,26 @@ export class ReviewScopeResolver {
108
134
  reviewedFrom: prevSha,
109
135
  carryover: buildCarryover(previous, changedPaths),
110
136
  };
111
- return { kind: "review", classification, diff, reviewedFrom: prevSha };
137
+ return { kind: "review", classification, diff, reviewedFrom: prevSha, ast };
112
138
  }
113
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;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
+ import type { AstFacts } from "./ast/types";
9
10
  import type { HoundsmanConfig } from "./config";
10
11
  import { churnToRecord, collectChurn, type DiffSource } from "./diff-source";
11
12
  import { matchesClassifyRule } from "./domain/watchdog";
@@ -69,12 +70,13 @@ export function matchWatchdogs<TId extends string>(
69
70
  watchdogs: Record<TId, WatchdogDefinition>,
70
71
  watchdogIds: readonly TId[],
71
72
  content?: string,
73
+ facts?: AstFacts,
72
74
  ): TId[] {
73
75
  const ids: TId[] = [];
74
76
  for (const id of watchdogIds) {
75
77
  if (
76
78
  watchdogs[id]?.classify?.some((rule) =>
77
- matchesClassifyRule(filePath, rule, content),
79
+ matchesClassifyRule(filePath, rule, content, facts),
78
80
  )
79
81
  ) {
80
82
  ids.push(id);
@@ -88,6 +90,13 @@ export function isExcludedPath(filePath: string, patterns: readonly RegExp[]): b
88
90
  return patterns.some((pattern) => pattern.test(filePath));
89
91
  }
90
92
 
93
+ /** Id do watchdog de último recurso (primeiro declarado), se houver. */
94
+ export function fallbackWatchdogId<TId extends string>(
95
+ config: HoundsmanConfig<TId>,
96
+ ): TId | undefined {
97
+ return config.watchdogIds.find((id) => config.watchdogs[id]?.fallback === true);
98
+ }
99
+
91
100
  // ---------------------------------------------------------------------------
92
101
  // Parsing de `git diff --name-status`
93
102
  // ---------------------------------------------------------------------------
@@ -142,12 +151,15 @@ export function classifyEntries<TId extends string>(
142
151
  entries: FileEntry[],
143
152
  config: HoundsmanConfig<TId>,
144
153
  churn: ChurnMap = {},
154
+ astFacts?: ReadonlyMap<string, AstFacts>,
145
155
  ): ClassificationResult {
146
156
  const watchdogFiles: Record<string, string[]> = {};
147
157
  const files: ClassificationResult["files"] = {};
148
158
  const unclassified: string[] = [];
149
159
  const excluded: string[] = [];
150
160
  const deleted: string[] = [];
161
+ // Watchdog de último recurso: cobre apenas arquivos sem outro dono.
162
+ const fallbackId = fallbackWatchdogId(config);
151
163
  // Leitura de conteúdo só é habilitada se algum watchdog declarar regra `content`.
152
164
  const needsContent = hasContentRules(config.watchdogs, config.watchdogIds);
153
165
 
@@ -167,15 +179,23 @@ export function classifyEntries<TId extends string>(
167
179
  }
168
180
 
169
181
  const content = needsContent ? readFileContent(path) : undefined;
170
- const ids: string[] = matchWatchdogs(
182
+ const facts = astFacts?.get(path);
183
+ const matched = matchWatchdogs(
171
184
  path,
172
185
  config.watchdogs,
173
186
  config.watchdogIds,
174
187
  content,
188
+ facts,
175
189
  );
190
+ const ids = fallbackId ? matched.filter((id) => id !== fallbackId) : matched;
176
191
 
177
192
  if (ids.length === 0) {
178
- unclassified.push(path);
193
+ if (fallbackId) {
194
+ watchdogFiles[fallbackId] ??= [];
195
+ watchdogFiles[fallbackId].push(path);
196
+ } else {
197
+ unclassified.push(path);
198
+ }
179
199
  continue;
180
200
  }
181
201
 
@@ -218,10 +238,11 @@ export function classifyRange<TId extends string>(
218
238
  head: string,
219
239
  config: HoundsmanConfig<TId>,
220
240
  diff?: DiffSource,
241
+ astFacts?: ReadonlyMap<string, AstFacts>,
221
242
  ): ClassificationResult {
222
243
  const entries = parseEntries(runGit(["diff", `${base}...${head}`, "--name-status"]));
223
244
  const churn = diff?.churn ?? collectChurn(base, head);
224
- const result = classifyEntries(entries, config, churnToRecord(churn));
245
+ const result = classifyEntries(entries, config, churnToRecord(churn), astFacts);
225
246
  result.base = base;
226
247
  result.head = head;
227
248
  return result;
@@ -5,13 +5,14 @@
5
5
  * pipeline de review sem dependências novas de SDK e com tipagem explícita
6
6
  * do request/response (`./jev-types`).
7
7
  *
8
- * Config: apenas `TYPESAFE_API_KEY` (via env). Origem e modelo da API são
9
- * fixos no código (constantes abaixo).
8
+ * Config: apenas `HOUNDSMAN_TYPE_SAFE_API_KEY` (via `core/env.ts`). Origem e
9
+ * modelo da API são fixos no código (constantes abaixo).
10
10
  *
11
11
  * Erros são normalizados em `JevError` — o chamador decide falhar-aberto.
12
12
  */
13
13
 
14
14
  import axios, { type AxiosInstance, isAxiosError } from "axios";
15
+ import { ENV_NAMES, env } from "../env";
15
16
  import type { JevEvaluateRequest, JevEvaluateResponse } from "./jev-types";
16
17
 
17
18
  const DEFAULT_BASE_URL = "https://api.typesafe.ai";
@@ -19,7 +20,7 @@ const DEFAULT_MODEL = "jev-latest";
19
20
  const DEFAULT_TIMEOUT_MS = 20_000;
20
21
  /** Timeout da verificação de credencial do `doctor` (mais curto que o de review). */
21
22
  const DEFAULT_PING_TIMEOUT_MS = 10_000;
22
- /** Caminho fixo do endpoinft System One. */
23
+ /** Caminho fixo do endpoint System One. */
23
24
  const JEV_PATH = "/v1/systemone";
24
25
 
25
26
  export class JevError extends Error {
@@ -39,7 +40,7 @@ export type JevCredentialCheck =
39
40
  | { ok: false; kind: "invalid" | "unreachable"; detail: string };
40
41
 
41
42
  export type JevClientOptions = {
42
- /** Chave de API (default: `process.env.TYPESAFE_API_KEY`). */
43
+ /** Chave de API (default: `env.typeSafeApiKey`). */
43
44
  apiKey?: string;
44
45
  /** Sobreposição da origem da API (default: constante `DEFAULT_BASE_URL`). */
45
46
  baseURL?: string;
@@ -57,7 +58,7 @@ export class JevClient {
57
58
  readonly model: string;
58
59
 
59
60
  constructor(options: JevClientOptions = {}) {
60
- this.apiKey = options.apiKey ?? process.env.TYPESAFE_API_KEY;
61
+ this.apiKey = options.apiKey ?? env.typeSafeApiKey;
61
62
  this.model = options.model ?? DEFAULT_MODEL;
62
63
  const baseURL = options.baseURL ?? DEFAULT_BASE_URL;
63
64
  this.http =
@@ -79,7 +80,7 @@ export class JevClient {
79
80
  */
80
81
  async evaluate(request: JevEvaluateRequest): Promise<JevEvaluateResponse> {
81
82
  if (!this.apiKey) {
82
- throw new JevError("TYPESAFE_API_KEY não configurada.");
83
+ throw new JevError(`${ENV_NAMES.typeSafeApiKey} não configurada.`);
83
84
  }
84
85
  try {
85
86
  const { data } = await this.http.post<JevEvaluateResponse>(
@@ -105,7 +106,7 @@ export class JevClient {
105
106
  return {
106
107
  ok: false,
107
108
  kind: "unreachable",
108
- detail: "TYPESAFE_API_KEY não configurada.",
109
+ detail: `${ENV_NAMES.typeSafeApiKey} não configurada.`,
109
110
  };
110
111
  }
111
112
  const request: JevEvaluateRequest = {
@@ -7,6 +7,7 @@
7
7
  * decide. O custo é uma chamada por arquivo ambíguo, não uma revisão completa.
8
8
  */
9
9
 
10
+ import { ENV_NAMES } from "../env";
10
11
  import { diffPatch, readFileAt } from "../git-revision";
11
12
  import type { JevClient } from "./jev-client";
12
13
  import type { JevAnswer, JevQuestion } from "./jev-types";
@@ -104,7 +105,7 @@ export class JevVerifier {
104
105
  confidence,
105
106
  checked: 0,
106
107
  skipped: cases.length,
107
- reason: "TYPESAFE_API_KEY não configurada",
108
+ reason: `${ENV_NAMES.typeSafeApiKey} não configurada`,
108
109
  };
109
110
  }
110
111
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Instância pronta do logger (`import { logger } from "../core/logging"`).
3
+ *
4
+ * Infraestrutura compartilhada por `cli`, `harness` e `server`. Módulos de
5
+ * negócio do core não escrevem saída.
6
+ */
7
+
8
+ import { Logger } from "./logger";
9
+
10
+ export const logger = new Logger();
11
+
12
+ export type {
13
+ LoggerLike,
14
+ LoggerOptions,
15
+ SpinnerLike,
16
+ } from "./logger";
17
+ export { Logger, SilentLogger } from "./logger";