@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.
- package/README.md +24 -4
- package/assets/grammars/tree-sitter-javascript.wasm +0 -0
- package/assets/grammars/tree-sitter-tsx.wasm +0 -0
- package/assets/grammars/tree-sitter-typescript.wasm +0 -0
- package/assets/grammars/web-tree-sitter.wasm +0 -0
- package/package.json +11 -3
- package/src/cli/commands/command.ts +5 -0
- package/src/cli/commands/doctor-command.ts +116 -24
- package/src/cli/commands/hunt-command.ts +25 -0
- package/src/cli/help.ts +17 -1
- package/src/cli/index.ts +10 -6
- package/src/core/ai/ai-client.ts +221 -0
- package/src/core/ai/ai-sdk-model-provider.ts +101 -0
- package/src/core/ai/errors.ts +36 -0
- package/src/core/ai/features/brief-enricher.ts +62 -0
- package/src/core/ai/features/classifier.ts +50 -0
- package/src/core/ai/features/context-summarizer.ts +31 -0
- package/src/core/ai/features/pre-analyzer.ts +51 -0
- package/src/core/ai/features/verifier.ts +45 -0
- package/src/core/ai/index.ts +56 -0
- package/src/core/ai/model-factory.ts +137 -0
- package/src/core/ai/prompts/base.ts +36 -0
- package/src/core/ai/prompts/brief.ts +87 -0
- package/src/core/ai/prompts/classification.ts +36 -0
- package/src/core/ai/prompts/context.ts +29 -0
- package/src/core/ai/prompts/pre-analysis.ts +49 -0
- package/src/core/ai/prompts/verify.ts +40 -0
- package/src/core/ai/schemas.ts +110 -0
- package/src/core/ai/types.ts +76 -0
- package/src/core/ai/usage-budget.ts +75 -0
- package/src/core/ai/usage-recorder.ts +79 -0
- package/src/core/assets.ts +16 -0
- package/src/core/ast/diff.ts +220 -0
- package/src/core/ast/impact.ts +127 -0
- package/src/core/ast/parser.ts +167 -0
- package/src/core/ast/service.ts +233 -0
- package/src/core/ast/symbols.ts +294 -0
- package/src/core/ast/types.ts +125 -0
- package/src/core/brief.ts +138 -0
- package/src/core/commands/findings.ts +59 -1
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +159 -10
- package/src/core/commands/update.ts +5 -3
- package/src/core/config.ts +85 -0
- package/src/core/defaults.ts +27 -0
- package/src/core/domain/watchdog.ts +43 -5
- package/src/core/engine/houndsman-engine.ts +16 -1
- package/src/core/engine/review-scope.ts +52 -6
- package/src/core/engine/review-session.ts +27 -2
- package/src/core/env.ts +101 -0
- package/src/core/file-classifier.ts +25 -4
- package/src/core/jev/jev-client.ts +8 -7
- package/src/core/jev/jev-verify.ts +2 -1
- package/src/core/logging/index.ts +17 -0
- package/src/core/logging/logger.ts +187 -0
- package/src/core/orchestrator.ts +357 -9
- package/src/core/project-config.ts +123 -1
- package/src/core/registry-check.ts +148 -6
- package/src/core/renderers/manifest-writer.ts +33 -0
- package/src/core/rule-text.ts +97 -0
- package/src/core/services/file-classifier.ts +10 -3
- package/src/core/services/verifier.ts +14 -3
- package/src/core/storage/paths.ts +6 -5
- package/src/core/storage/types.ts +6 -0
- package/src/core/storage/work-area.ts +12 -0
- package/src/core/types.ts +37 -1
- package/src/core/verify.ts +17 -5
- package/src/harness/README.md +15 -0
- package/src/harness/agent/agent-context.ts +33 -0
- package/src/harness/agent/agent-errors.ts +30 -0
- package/src/harness/agent/agent-factory.ts +45 -0
- package/src/harness/agent/agent-reporter.ts +34 -0
- package/src/harness/agent/agent-result.ts +16 -0
- package/src/harness/agent/agent-runner.ts +32 -0
- package/src/harness/agent/agent-scheduler.ts +37 -0
- package/src/harness/agent/review-agent.ts +391 -0
- package/src/harness/config/harness-config.ts +84 -0
- package/src/harness/hunt/hunt-harness.ts +83 -0
- package/src/harness/hunt/hunt-options.ts +75 -0
- package/src/harness/hunt/hunt-result.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +372 -0
- package/src/harness/hunt/review-planner.ts +98 -0
- package/src/harness/index.ts +36 -0
- package/src/harness/investigation/agent-state.ts +134 -0
- package/src/harness/investigation/evidence-verifier.ts +129 -0
- package/src/harness/investigation/evidence.ts +110 -0
- package/src/harness/investigation/finding-critic.ts +96 -0
- package/src/harness/investigation/investigation-planner.ts +118 -0
- package/src/harness/investigation/investigation-task.ts +10 -0
- package/src/harness/investigation/review-journal.ts +87 -0
- package/src/harness/investigation/rule-analyzer.ts +178 -0
- package/src/harness/investigation/rule-signals.ts +110 -0
- package/src/harness/prompts/agent-schemas.ts +57 -0
- package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
- package/src/harness/prompts/states/conclude-prompt.ts +16 -0
- package/src/harness/prompts/states/investigate-prompt.ts +37 -0
- package/src/harness/prompts/states/orient-prompt.ts +15 -0
- package/src/harness/prompts/states/verify-prompt.ts +22 -0
- package/src/harness/tools/agent-tool.ts +102 -0
- package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
- package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
- package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
- package/src/harness/tools/builtin/find-references-tool.ts +66 -0
- package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
- package/src/harness/tools/builtin/helpers.ts +115 -0
- package/src/harness/tools/builtin/impact-tool.ts +48 -0
- package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
- package/src/harness/tools/builtin/index.ts +59 -0
- package/src/harness/tools/builtin/list-files-tool.ts +85 -0
- package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
- package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
- package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
- package/src/harness/tools/builtin/search-code-tool.ts +65 -0
- package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
- package/src/harness/tools/path-guard.ts +90 -0
- package/src/harness/tools/result-limiter.ts +49 -0
- package/src/harness/tools/secret-redactor.ts +54 -0
- package/src/harness/tools/tool-cache.ts +50 -0
- package/src/harness/tools/tool-call-budget.ts +60 -0
- package/src/harness/tools/tool-context.ts +5 -0
- package/src/harness/tools/tool-middleware.ts +105 -0
- package/src/harness/tools/tool-policy.ts +51 -0
- package/src/harness/tools/tool-registry.ts +112 -0
- package/src/harness/tools/tool-result.ts +15 -0
- package/src/harness/tools/toolbox.ts +36 -0
- package/src/harness/validation/finding-validator.ts +119 -0
- package/src/index.ts +46 -0
- package/src/server/client/utils/constants.ts +27 -5
- package/src/server/client/utils/types.ts +6 -0
- package/src/server/index.ts +3 -2
- package/src/skill/OUTPUT-FORMAT.md +12 -0
- package/src/skill/SETUP.md +6 -2
- package/src/skill/hunt.md +14 -2
- package/src/skill/verify.md +1 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
- package/src/tests/ai-core.test.ts +120 -0
- package/src/tests/ai-features.test.ts +183 -0
- package/src/tests/ast.test.ts +335 -0
- package/src/tests/env.test.ts +69 -0
- package/src/tests/fallback.test.ts +172 -0
- package/src/tests/fixtures/fake-agent-tools.ts +69 -0
- package/src/tests/fixtures/fake-investigation.ts +64 -0
- package/src/tests/fixtures/fake-model-provider.ts +48 -0
- package/src/tests/harness-agent.test.ts +122 -0
- package/src/tests/harness-investigation.test.ts +206 -0
- package/src/tests/harness-tools.test.ts +148 -0
- package/src/tests/hunt-command.test.ts +212 -0
- package/src/tests/logger.test.ts +83 -0
- package/src/tests/review-scope.test.ts +11 -11
- package/src/tests/rule-analyzer.test.ts +61 -0
- package/src/tests/tool-contracts.test.ts +82 -0
- package/src/tests/verify.test.ts +1 -1
package/src/core/defaults.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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) =>
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
.
|
|
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
|
|
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(
|
|
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(
|
|
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. */
|
package/src/core/env.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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: `
|
|
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 ??
|
|
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(
|
|
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:
|
|
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:
|
|
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";
|