@payconductor/houndsman 0.8.0 → 0.10.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/package.json +1 -1
- package/src/core/ai/ai-client.ts +21 -0
- package/src/core/ai/ai-sdk-model-provider.ts +43 -18
- package/src/core/ai/features/technical-summarizer.ts +43 -0
- package/src/core/ai/index.ts +4 -1
- package/src/core/ai/prompts/context.ts +3 -3
- package/src/core/ai/prompts/technical.ts +30 -0
- package/src/core/ai/provider-capabilities.ts +45 -0
- package/src/core/ai/schemas.ts +19 -3
- package/src/core/ai/types.ts +3 -1
- package/src/core/brief.ts +25 -4
- package/src/core/commands/scaffold.ts +5 -0
- package/src/core/config.ts +17 -1
- package/src/core/git-runner.ts +8 -1
- package/src/core/orchestrator.ts +114 -2
- package/src/core/paths.ts +3 -0
- package/src/core/pr-context.ts +11 -6
- package/src/core/project-config.ts +9 -1
- package/src/core/renderers/resume-renderer.ts +17 -34
- package/src/core/report-builder.ts +6 -6
- package/src/core/services/worktree-manager.ts +29 -5
- package/src/core/storage/types.ts +1 -0
- package/src/core/technical-summary.ts +398 -0
- package/src/harness/agent/agent-factory.ts +1 -0
- package/src/harness/agent/review-agent.ts +11 -1
- package/src/harness/config/harness-config.ts +6 -1
- package/src/index.ts +2 -0
- package/src/server/client/App.tsx +2 -1
- package/src/server/client/PrsSection.tsx +296 -0
- package/src/server/client/ResumePanel.tsx +10 -13
- package/src/server/client/RunsSection.tsx +73 -0
- package/src/server/client/Sidebar.tsx +171 -413
- package/src/server/client/sidebar-sections.tsx +73 -0
- package/src/server/client/utils/constants.ts +7 -2
- package/src/server/client/utils/sidebar-prefs.ts +64 -0
- package/src/server/client/utils/types.ts +1 -0
- package/src/server/index.ts +23 -409
- package/src/server/styles.css +454 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
- package/src/tests/ai-core.test.ts +71 -0
- package/src/tests/ai-features.test.ts +36 -0
- package/src/tests/baseline.test.ts +4 -1
- package/src/tests/harness-agent.test.ts +19 -0
- package/src/tests/review-resume.test.ts +36 -8
- package/src/tests/review-scope.test.ts +67 -0
- package/src/tests/review-scripts.test.ts +2 -2
- package/src/tests/server.test.ts +16 -1
- package/src/tests/sidebar-sections.test.ts +94 -0
- package/src/tests/technical-summary.test.ts +176 -0
- package/src/tests/worktree.test.ts +39 -4
- package/assets/logo-legacy.png +0 -0
package/src/core/pr-context.ts
CHANGED
|
@@ -229,13 +229,17 @@ export function renderContextBrief(
|
|
|
229
229
|
|
|
230
230
|
lines.push("## Regras", "");
|
|
231
231
|
lines.push(
|
|
232
|
-
"-
|
|
232
|
+
"- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
|
|
233
233
|
);
|
|
234
234
|
lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
|
|
235
|
-
lines.push("- `objective`: 1 frase (≤ 200 caracteres) sobre o que a PR entrega.");
|
|
236
|
-
lines.push("- `motivation`: 1 frase (≤ 240 caracteres) sobre o porquê.");
|
|
237
235
|
lines.push(
|
|
238
|
-
"- `
|
|
236
|
+
"- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
|
|
237
|
+
);
|
|
238
|
+
lines.push(
|
|
239
|
+
"- `motivation`: até 3 frases (≤ 480 caracteres) sobre o porquê e o impacto.",
|
|
240
|
+
);
|
|
241
|
+
lines.push(
|
|
242
|
+
"- `changeSummary`: no máximo 6 bullets (≤ 240 caracteres cada), focados em comportamento — não liste arquivos.",
|
|
239
243
|
);
|
|
240
244
|
lines.push(
|
|
241
245
|
"- NÃO copie a descrição da PR: sintetize. Copie `source` do contexto bruto.",
|
|
@@ -253,7 +257,8 @@ const CONTEXT_SUMMARY_TEMPLATE = {
|
|
|
253
257
|
base: "main",
|
|
254
258
|
head: "feature/x",
|
|
255
259
|
},
|
|
256
|
-
objective: "O que a PR entrega, em 1
|
|
257
|
-
motivation:
|
|
260
|
+
objective: "O que a PR entrega, em 1-2 frases.",
|
|
261
|
+
motivation:
|
|
262
|
+
"Por que a mudança é necessária, com o problema e o impacto (até 3 frases).",
|
|
258
263
|
changeSummary: ["Bullet de comportamento 1", "Bullet de comportamento 2"],
|
|
259
264
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
10
|
AiProviderKind,
|
|
11
|
+
AiProviderOptions,
|
|
11
12
|
HoundsmanAi,
|
|
12
13
|
HoundsmanAiFeatures,
|
|
13
14
|
HoundsmanAst,
|
|
@@ -138,6 +139,12 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
138
139
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/** Opções do AI SDK por provider (undefined quando ausentes/vazias). */
|
|
143
|
+
get aiProviderOptions(): AiProviderOptions | undefined {
|
|
144
|
+
const value = this.ai.providerOptions;
|
|
145
|
+
return value && Object.keys(value).length > 0 ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
141
148
|
get aiTimeoutMs(): number {
|
|
142
149
|
return positive(this.ai.timeoutMs, DEFAULT_AI_TIMEOUT_MS);
|
|
143
150
|
}
|
|
@@ -152,12 +159,13 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
152
159
|
return value !== undefined && value > 0 ? value : undefined;
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
/** Features resolvidas com defaults (`context`/`brief` ligadas). */
|
|
162
|
+
/** Features resolvidas com defaults (`context`/`brief`/`technical` ligadas). */
|
|
156
163
|
get aiFeatures(): Required<HoundsmanAiFeatures> {
|
|
157
164
|
const features = this.ai.features ?? {};
|
|
158
165
|
return {
|
|
159
166
|
context: features.context !== false,
|
|
160
167
|
brief: features.brief !== false,
|
|
168
|
+
technical: features.technical !== false,
|
|
161
169
|
classification: features.classification === true,
|
|
162
170
|
preAnalysis: features.preAnalysis === true,
|
|
163
171
|
verify: features.verify === true,
|
|
@@ -3,19 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Artefato somente-leitura para humanos: não entra em briefs, não é lido pelos
|
|
5
5
|
* watchdogs e não participa de nenhuma etapa seguinte. Une o contexto de
|
|
6
|
-
* negócio da PR aos
|
|
6
|
+
* negócio da PR aos pontos técnicos estruturais da revisão, com limite de
|
|
7
7
|
* `RESUME_MAX_CHARS`.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
11
|
-
ContextSummary,
|
|
12
|
-
MergedFinding,
|
|
13
|
-
WatchdogNameResolver,
|
|
14
|
-
} from "../report-builder";
|
|
10
|
+
import type { ContextSummary, MergedFinding } from "../report-builder";
|
|
15
11
|
import { systemClock } from "../services/clock";
|
|
12
|
+
import type { TechnicalSummary } from "../technical-summary";
|
|
16
13
|
|
|
17
14
|
/** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
|
|
18
|
-
export const RESUME_MAX_CHARS =
|
|
15
|
+
export const RESUME_MAX_CHARS = 5000;
|
|
19
16
|
|
|
20
17
|
/** Dados de classificação usados no resumo. */
|
|
21
18
|
export type ResumeClassification = {
|
|
@@ -30,11 +27,10 @@ export type ReviewResumeInput = {
|
|
|
30
27
|
context?: ContextSummary | null;
|
|
31
28
|
classification: ResumeClassification;
|
|
32
29
|
findings: readonly MergedFinding[];
|
|
33
|
-
|
|
30
|
+
/** Pontos técnicos estruturais (não achados). */
|
|
31
|
+
technical?: TechnicalSummary | null;
|
|
34
32
|
};
|
|
35
33
|
|
|
36
|
-
const identityName: WatchdogNameResolver = (id) => id;
|
|
37
|
-
|
|
38
34
|
const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
|
|
39
35
|
blocking: "❌ Mudanças necessárias",
|
|
40
36
|
attention: "⚠️ Aprovado com ressalvas",
|
|
@@ -80,36 +76,24 @@ function businessSection(input: ReviewResumeInput): string[] {
|
|
|
80
76
|
if (context.objective) lines.push(context.objective, "");
|
|
81
77
|
if (context.motivation) lines.push(`**Por quê:** ${context.motivation}`, "");
|
|
82
78
|
if (context.changeSummary && context.changeSummary.length > 0) {
|
|
83
|
-
for (const item of context.changeSummary.slice(0,
|
|
84
|
-
lines.push(`- ${truncate(item,
|
|
79
|
+
for (const item of context.changeSummary.slice(0, 6)) {
|
|
80
|
+
lines.push(`- ${truncate(item, 240)}`);
|
|
85
81
|
}
|
|
86
82
|
lines.push("");
|
|
87
83
|
}
|
|
88
84
|
return lines;
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
/**
|
|
92
|
-
function technicalSection(
|
|
93
|
-
findings: readonly MergedFinding[],
|
|
94
|
-
nameOf: WatchdogNameResolver,
|
|
95
|
-
): string[] {
|
|
96
|
-
const relevant = findings
|
|
97
|
-
.filter(
|
|
98
|
-
(finding) =>
|
|
99
|
-
finding.severity === "blocking" || finding.severity === "attention",
|
|
100
|
-
)
|
|
101
|
-
.slice(0, 5);
|
|
87
|
+
/** Pontos técnicos estruturais (módulos, contratos, impacto) — não achados. */
|
|
88
|
+
function technicalSection(summary: TechnicalSummary | null | undefined): string[] {
|
|
102
89
|
const lines = ["## Pontos técnicos", ""];
|
|
103
|
-
if (
|
|
104
|
-
lines.push("
|
|
90
|
+
if (!summary || (!summary.overview && summary.highlights.length === 0)) {
|
|
91
|
+
lines.push("Sem mudanças estruturais detectadas nesta revisão.", "");
|
|
105
92
|
return lines;
|
|
106
93
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
lines.push(
|
|
111
|
-
`- \`${location}\` — ${truncate(finding.message, 130)}${watchdogs ? ` _(${watchdogs})_` : ""}`,
|
|
112
|
-
);
|
|
94
|
+
if (summary.overview) lines.push(summary.overview, "");
|
|
95
|
+
for (const highlight of summary.highlights) {
|
|
96
|
+
lines.push(`- **${highlight.area}** — ${truncate(highlight.detail, 300)}`);
|
|
113
97
|
}
|
|
114
98
|
lines.push("");
|
|
115
99
|
return lines;
|
|
@@ -125,10 +109,9 @@ function clamp(markdown: string, max: number): string {
|
|
|
125
109
|
|
|
126
110
|
/**
|
|
127
111
|
* Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
|
|
128
|
-
* nasce do contexto já disponível e dos
|
|
112
|
+
* nasce do contexto já disponível e dos pontos técnicos persistidos.
|
|
129
113
|
*/
|
|
130
114
|
export function renderReviewResume(input: ReviewResumeInput): string {
|
|
131
|
-
const nameOf = input.nameOf ?? identityName;
|
|
132
115
|
const blockers = input.findings.filter((f) => f.severity === "blocking").length;
|
|
133
116
|
const attention = input.findings.filter((f) => f.severity === "attention").length;
|
|
134
117
|
const status =
|
|
@@ -144,7 +127,7 @@ export function renderReviewResume(input: ReviewResumeInput): string {
|
|
|
144
127
|
`**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
|
|
145
128
|
"",
|
|
146
129
|
...businessSection(input),
|
|
147
|
-
...technicalSection(input.
|
|
130
|
+
...technicalSection(input.technical),
|
|
148
131
|
];
|
|
149
132
|
|
|
150
133
|
return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
|
|
@@ -63,14 +63,14 @@ export type ContextSummary = {
|
|
|
63
63
|
const VALID_SEVERITIES = new Set<string>(SEVERITY_ORDER);
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Limites do resumo da PR:
|
|
67
|
-
*
|
|
66
|
+
* Limites do resumo da PR: o contexto abre o relatório e o resumo final, mas
|
|
67
|
+
* não pode inflar os briefs — a injeção nos briefs é recortada em `brief.ts`.
|
|
68
68
|
*/
|
|
69
69
|
const CONTEXT_LIMITS = {
|
|
70
|
-
objective:
|
|
71
|
-
motivation:
|
|
72
|
-
changeSummaryItem:
|
|
73
|
-
changeSummaryItems:
|
|
70
|
+
objective: 400,
|
|
71
|
+
motivation: 480,
|
|
72
|
+
changeSummaryItem: 240,
|
|
73
|
+
changeSummaryItems: 6,
|
|
74
74
|
} as const;
|
|
75
75
|
|
|
76
76
|
/** Corta `value` em `max` caracteres, sinalizando com reticências. */
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* Um worktree por branch revisada, criado em **detached HEAD** no commit da PR.
|
|
5
5
|
* Detached evita o conflito "branch já checada" com o clone principal e é o
|
|
6
6
|
* modo correto para revisão (somente leitura). Vive fora do projeto, em
|
|
7
|
-
* `<
|
|
7
|
+
* `<baseDir>/<repoSlug>/<branchSlug>`.
|
|
8
|
+
*
|
|
9
|
+
* Os dois segmentos são `<trecho-legível>-<hash8>`: o trecho legível é
|
|
10
|
+
* truncado porque o checkout de uma PR herda a profundidade dos arquivos do
|
|
11
|
+
* projeto, e o Windows (MAX_PATH = 260) estoura "Filename too long" com
|
|
12
|
+
* repo+branch longos. O hash mantém o nome determinístico e único.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import { createHash } from "node:crypto";
|
|
@@ -64,6 +69,22 @@ export type WorktreeManagerOptions = {
|
|
|
64
69
|
baseDir?: string;
|
|
65
70
|
};
|
|
66
71
|
|
|
72
|
+
/** Limite do trecho legível no diretório do repositório. */
|
|
73
|
+
const REPO_SLUG_MAX = 24;
|
|
74
|
+
/** Limite do trecho legível no diretório da branch. */
|
|
75
|
+
const BRANCH_SLUG_MAX = 20;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `<trecho legível truncado>-<hash8>`: curto o bastante para o MAX_PATH do
|
|
79
|
+
* Windows e estável para o mesmo par (repositório, branch).
|
|
80
|
+
*/
|
|
81
|
+
function hashName(key: string, readable: string, max: number, fallback: string): string {
|
|
82
|
+
const hash = createHash("sha1").update(key).digest("hex").slice(0, 8);
|
|
83
|
+
// Ponto/traço no fim do nome é descartado pelo Windows; corte antes do hash.
|
|
84
|
+
const head = readable.slice(0, max).replace(/[-._]+$/, "");
|
|
85
|
+
return `${head || fallback}-${hash}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
/** Slug estável e legível para o diretório do repositório. */
|
|
68
89
|
export function repoSlug(repoKey: string): string {
|
|
69
90
|
const clean = repoKey
|
|
@@ -71,8 +92,12 @@ export function repoSlug(repoKey: string): string {
|
|
|
71
92
|
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
72
93
|
.replace(/^-+|-+$/g, "")
|
|
73
94
|
.toLowerCase();
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
return hashName(repoKey, clean.slice(-REPO_SLUG_MAX), REPO_SLUG_MAX, "repo");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Slug estável e curto para o diretório de uma branch. */
|
|
99
|
+
export function branchSlug(branch: string): string {
|
|
100
|
+
return hashName(branch, sanitizeBranch(branch), BRANCH_SLUG_MAX, "branch");
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
/** Normaliza um path para comparação (Windows é case-insensitive). */
|
|
@@ -154,8 +179,7 @@ export class WorktreeManager {
|
|
|
154
179
|
|
|
155
180
|
/** Caminho determinístico do worktree de uma branch. */
|
|
156
181
|
pathFor(branch: string): string {
|
|
157
|
-
|
|
158
|
-
return join(this.rootDir, slug);
|
|
182
|
+
return join(this.rootDir, branchSlug(branch));
|
|
159
183
|
}
|
|
160
184
|
|
|
161
185
|
/** Todos os worktrees registrados no repositório. */
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pontos técnicos estruturais do resumo final (`technical-summary.json`).
|
|
3
|
+
*
|
|
4
|
+
* Descrevem a arquitetura da mudança (módulos, contratos, implementações,
|
|
5
|
+
* dependências e impacto) — nunca os achados da revisão. A IA apenas refina a
|
|
6
|
+
* redação; sem ela, a heurística abaixo é o resultado final.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AstAnalysis, AstImpact, AstSymbol, AstSymbolKind } from "./ast/types";
|
|
10
|
+
import type { ClassificationResult } from "./file-classifier";
|
|
11
|
+
|
|
12
|
+
/** Destaque técnico exibido como um bullet do resumo. */
|
|
13
|
+
export type TechnicalHighlight = {
|
|
14
|
+
/** Rótulo curto (ex.: "Contratos", "Implementações"). */
|
|
15
|
+
area: string;
|
|
16
|
+
/** Detalhe em uma linha, com paths/símbolos. */
|
|
17
|
+
detail: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Pontos técnicos de uma revisão. */
|
|
21
|
+
export type TechnicalSummary = {
|
|
22
|
+
/** `ai` quando o texto veio do modelo; `deterministic` no fallback. */
|
|
23
|
+
source: "ai" | "deterministic";
|
|
24
|
+
/** Commit coberto — permite saber a que revisão o resumo se refere. */
|
|
25
|
+
headSha?: string;
|
|
26
|
+
/** Parágrafo curto de abertura (opcional). */
|
|
27
|
+
overview?: string;
|
|
28
|
+
highlights: TechnicalHighlight[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Entrada mínima para montar os destaques. */
|
|
32
|
+
export type TechnicalInput = {
|
|
33
|
+
classification: Pick<ClassificationResult, "files" | "churn">;
|
|
34
|
+
ast?: ReadonlyMap<string, AstAnalysis>;
|
|
35
|
+
impact?: AstImpact;
|
|
36
|
+
headSha?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Entrada do payload enviado à IA (inclui contexto de negócio e domínios). */
|
|
40
|
+
export type TechnicalPromptInput = TechnicalInput & {
|
|
41
|
+
branch: string;
|
|
42
|
+
context?: {
|
|
43
|
+
objective?: string;
|
|
44
|
+
motivation?: string;
|
|
45
|
+
changeSummary?: string[];
|
|
46
|
+
} | null;
|
|
47
|
+
domains?: readonly string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const MAX_HIGHLIGHTS = 6;
|
|
51
|
+
const MAX_DETAIL = 240;
|
|
52
|
+
|
|
53
|
+
/** Corta o texto em `max` caracteres, sinalizando com reticências. */
|
|
54
|
+
function truncate(value: string, max: number): string {
|
|
55
|
+
const text = value.trim().replace(/\s+/g, " ");
|
|
56
|
+
return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Diretório do arquivo (`(raiz)` para paths sem pasta). */
|
|
60
|
+
function moduleOf(path: string): string {
|
|
61
|
+
const slash = path.lastIndexOf("/");
|
|
62
|
+
return slash > 0 ? path.slice(0, slash) : "(raiz)";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `true` para pacotes externos (fora do projeto e de aliases locais). */
|
|
66
|
+
function isExternal(source: string): boolean {
|
|
67
|
+
return (
|
|
68
|
+
!source.startsWith(".") &&
|
|
69
|
+
!source.startsWith("/") &&
|
|
70
|
+
!source.startsWith("#") &&
|
|
71
|
+
!source.startsWith("@/")
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `true` para símbolos que representam contratos públicos. */
|
|
76
|
+
function isContract(kind: AstSymbolKind): boolean {
|
|
77
|
+
return kind === "interface" || kind === "type" || kind === "enum";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type SymbolRef = { symbol: AstSymbol; file: string };
|
|
81
|
+
|
|
82
|
+
/** Lista curta de símbolos com o arquivo de origem. */
|
|
83
|
+
function symbolDetail(refs: SymbolRef[], max = 4): string {
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
for (const ref of refs) {
|
|
87
|
+
const key = `${ref.file}|${ref.symbol.name}`;
|
|
88
|
+
if (seen.has(key)) continue;
|
|
89
|
+
seen.add(key);
|
|
90
|
+
parts.push(`\`${ref.symbol.name}\` (${ref.file})`);
|
|
91
|
+
if (parts.length === max) break;
|
|
92
|
+
}
|
|
93
|
+
const total = new Set(refs.map((ref) => `${ref.file}|${ref.symbol.name}`)).size;
|
|
94
|
+
return `${parts.join(", ")}${total > parts.length ? ` +${total - parts.length}` : ""}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type SignatureRef = {
|
|
98
|
+
name: string;
|
|
99
|
+
before: string;
|
|
100
|
+
after: string;
|
|
101
|
+
file: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Assinaturas alteradas, com o `antes → depois`. */
|
|
105
|
+
function signatureDetail(changes: SignatureRef[], max = 3): string {
|
|
106
|
+
return changes
|
|
107
|
+
.slice(0, max)
|
|
108
|
+
.map(
|
|
109
|
+
(change) =>
|
|
110
|
+
`\`${change.name}\` (${truncate(change.before, 60)} → ${truncate(change.after, 60)})`,
|
|
111
|
+
)
|
|
112
|
+
.join(", ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type ExportRef = {
|
|
116
|
+
name: string;
|
|
117
|
+
change: "added" | "removed";
|
|
118
|
+
file: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type PromptSymbolRef = { name: string; kind: string; file: string };
|
|
122
|
+
|
|
123
|
+
/** Exports adicionados/removidos, com o arquivo. */
|
|
124
|
+
function exportDetail(changes: ExportRef[], max = 4): string {
|
|
125
|
+
return changes
|
|
126
|
+
.slice(0, max)
|
|
127
|
+
.map((change) => `\`${change.name}\` (${change.change}, ${change.file})`)
|
|
128
|
+
.join(", ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Agrupa paths por diretório, do maior para o menor grupo. */
|
|
132
|
+
function groupByModule(paths: string[]): [string, number][] {
|
|
133
|
+
const counts = new Map<string, number>();
|
|
134
|
+
for (const path of paths) {
|
|
135
|
+
const dir = moduleOf(path);
|
|
136
|
+
counts.set(dir, (counts.get(dir) ?? 0) + 1);
|
|
137
|
+
}
|
|
138
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Monta os destaques determinísticos. Prioriza símbolos do AST; sem fatos
|
|
143
|
+
* estruturais, agrupa os arquivos novos por diretório. Nunca lança.
|
|
144
|
+
*/
|
|
145
|
+
export function buildTechnicalSummary(input: TechnicalInput): TechnicalSummary {
|
|
146
|
+
const highlights: TechnicalHighlight[] = [];
|
|
147
|
+
const files = input.classification.files;
|
|
148
|
+
const added = Object.entries(files)
|
|
149
|
+
.filter(([, file]) => file.status === "added")
|
|
150
|
+
.map(([path]) => path);
|
|
151
|
+
const deleted = Object.entries(files)
|
|
152
|
+
.filter(([, file]) => file.status === "deleted")
|
|
153
|
+
.map(([path]) => path);
|
|
154
|
+
|
|
155
|
+
const contracts: SymbolRef[] = [];
|
|
156
|
+
const implementations: SymbolRef[] = [];
|
|
157
|
+
const functions: SymbolRef[] = [];
|
|
158
|
+
const signatures: SignatureRef[] = [];
|
|
159
|
+
const exportsChanged: ExportRef[] = [];
|
|
160
|
+
const imports = new Set<string>();
|
|
161
|
+
|
|
162
|
+
if (input.ast) {
|
|
163
|
+
for (const [file, analysis] of input.ast) {
|
|
164
|
+
const delta = analysis.delta;
|
|
165
|
+
if (!delta) continue;
|
|
166
|
+
for (const symbol of delta.addedSymbols) {
|
|
167
|
+
if (isContract(symbol.kind)) contracts.push({ symbol, file });
|
|
168
|
+
else if (symbol.kind === "class") implementations.push({ symbol, file });
|
|
169
|
+
else if (symbol.kind === "function") functions.push({ symbol, file });
|
|
170
|
+
}
|
|
171
|
+
for (const change of delta.changedSignatures) {
|
|
172
|
+
if (!change.exported) continue;
|
|
173
|
+
signatures.push({
|
|
174
|
+
name: change.name,
|
|
175
|
+
before: change.before,
|
|
176
|
+
after: change.after,
|
|
177
|
+
file,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const change of delta.changedExports) {
|
|
181
|
+
exportsChanged.push({
|
|
182
|
+
name: change.name,
|
|
183
|
+
change: change.change,
|
|
184
|
+
file,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
for (const source of delta.addedImports) {
|
|
188
|
+
if (isExternal(source)) imports.add(source);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (contracts.length > 0) {
|
|
194
|
+
highlights.push({ area: "Contratos", detail: symbolDetail(contracts) });
|
|
195
|
+
}
|
|
196
|
+
if (implementations.length > 0) {
|
|
197
|
+
highlights.push({
|
|
198
|
+
area: "Implementações",
|
|
199
|
+
detail: symbolDetail(implementations),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (functions.length > 0 && contracts.length === 0 && implementations.length === 0) {
|
|
203
|
+
highlights.push({ area: "Funções", detail: symbolDetail(functions) });
|
|
204
|
+
}
|
|
205
|
+
if (signatures.length > 0) {
|
|
206
|
+
highlights.push({
|
|
207
|
+
area: "Assinaturas alteradas",
|
|
208
|
+
detail: signatureDetail(signatures),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (exportsChanged.length > 0) {
|
|
212
|
+
highlights.push({
|
|
213
|
+
area: "Exports alterados",
|
|
214
|
+
detail: exportDetail(exportsChanged),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (imports.size > 0) {
|
|
218
|
+
highlights.push({
|
|
219
|
+
area: "Novas dependências",
|
|
220
|
+
detail: [...imports]
|
|
221
|
+
.slice(0, 6)
|
|
222
|
+
.map((source) => `\`${source}\``)
|
|
223
|
+
.join(", "),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Sem fatos do AST, a estrutura visível é o conjunto de diretórios novos.
|
|
228
|
+
if (highlights.length === 0) {
|
|
229
|
+
for (const [dir, count] of groupByModule(added).slice(0, 3)) {
|
|
230
|
+
highlights.push({
|
|
231
|
+
area: "Estrutura",
|
|
232
|
+
detail: `Novo diretório \`${dir}\` com ${count} arquivo(s).`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (deleted.length > 0 && highlights.length < MAX_HIGHLIGHTS) {
|
|
238
|
+
highlights.push({
|
|
239
|
+
area: "Remoções",
|
|
240
|
+
detail: `${deleted
|
|
241
|
+
.slice(0, 4)
|
|
242
|
+
.map((path) => `\`${path}\``)
|
|
243
|
+
.join(", ")}${deleted.length > 4 ? ` +${deleted.length - 4}` : ""}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
input.impact &&
|
|
249
|
+
input.impact.affectedFiles.length > 0 &&
|
|
250
|
+
highlights.length < MAX_HIGHLIGHTS
|
|
251
|
+
) {
|
|
252
|
+
const exported = input.impact.exportedChanges.length;
|
|
253
|
+
highlights.push({
|
|
254
|
+
area: "Impacto",
|
|
255
|
+
detail:
|
|
256
|
+
`${input.impact.affectedFiles.length} arquivo(s) importam os alterados` +
|
|
257
|
+
(exported > 0 ? `; ${exported} export(s) público(s) alterado(s)` : "") +
|
|
258
|
+
".",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
source: "deterministic",
|
|
264
|
+
headSha: input.headSha,
|
|
265
|
+
highlights: highlights.slice(0, MAX_HIGHLIGHTS).map((highlight) => ({
|
|
266
|
+
area: truncate(highlight.area, 80),
|
|
267
|
+
detail: truncate(highlight.detail, MAX_DETAIL),
|
|
268
|
+
})),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Payload compacto enviado ao modelo (nunca arquivos inteiros). */
|
|
273
|
+
export function buildTechnicalPromptPayload(
|
|
274
|
+
input: TechnicalPromptInput,
|
|
275
|
+
): Record<string, unknown> {
|
|
276
|
+
const files = Object.entries(input.classification.files)
|
|
277
|
+
.map(([path, file]) => ({
|
|
278
|
+
path,
|
|
279
|
+
status: file.status,
|
|
280
|
+
additions: input.classification.churn[path]?.additions ?? 0,
|
|
281
|
+
deletions: input.classification.churn[path]?.deletions ?? 0,
|
|
282
|
+
}))
|
|
283
|
+
.sort(
|
|
284
|
+
(a, b) =>
|
|
285
|
+
b.additions + b.deletions - (a.additions + a.deletions) ||
|
|
286
|
+
a.path.localeCompare(b.path),
|
|
287
|
+
)
|
|
288
|
+
.slice(0, 80);
|
|
289
|
+
|
|
290
|
+
const contracts: PromptSymbolRef[] = [];
|
|
291
|
+
const implementations: PromptSymbolRef[] = [];
|
|
292
|
+
const signatures: SignatureRef[] = [];
|
|
293
|
+
const exportsChanged: ExportRef[] = [];
|
|
294
|
+
const imports = new Set<string>();
|
|
295
|
+
const deletedFiles: string[] = [];
|
|
296
|
+
|
|
297
|
+
for (const [path, file] of Object.entries(input.classification.files)) {
|
|
298
|
+
if (file.status === "deleted") deletedFiles.push(path);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (input.ast) {
|
|
302
|
+
for (const [file, analysis] of input.ast) {
|
|
303
|
+
const delta = analysis.delta;
|
|
304
|
+
if (!delta) continue;
|
|
305
|
+
for (const symbol of delta.addedSymbols) {
|
|
306
|
+
if (isContract(symbol.kind)) {
|
|
307
|
+
contracts.push({ name: symbol.name, kind: symbol.kind, file });
|
|
308
|
+
} else if (symbol.kind === "class") {
|
|
309
|
+
implementations.push({ name: symbol.name, kind: symbol.kind, file });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const change of delta.changedSignatures) {
|
|
313
|
+
if (change.exported) {
|
|
314
|
+
signatures.push({
|
|
315
|
+
name: change.name,
|
|
316
|
+
before: change.before,
|
|
317
|
+
after: change.after,
|
|
318
|
+
file,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const change of delta.changedExports) {
|
|
323
|
+
exportsChanged.push({
|
|
324
|
+
name: change.name,
|
|
325
|
+
change: change.change,
|
|
326
|
+
file,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
for (const source of delta.addedImports) {
|
|
330
|
+
if (isExternal(source)) imports.add(source);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
branch: input.branch,
|
|
337
|
+
headSha: input.headSha,
|
|
338
|
+
business: input.context
|
|
339
|
+
? {
|
|
340
|
+
objective: input.context.objective,
|
|
341
|
+
motivation: input.context.motivation,
|
|
342
|
+
changeSummary: input.context.changeSummary,
|
|
343
|
+
}
|
|
344
|
+
: undefined,
|
|
345
|
+
domains: input.domains ?? [],
|
|
346
|
+
files,
|
|
347
|
+
structure: {
|
|
348
|
+
contracts: contracts.slice(0, 40),
|
|
349
|
+
implementations: implementations.slice(0, 40),
|
|
350
|
+
changedSignatures: signatures.slice(0, 20),
|
|
351
|
+
changedExports: exportsChanged.slice(0, 20),
|
|
352
|
+
addedImports: [...imports].slice(0, 20),
|
|
353
|
+
deletedFiles: deletedFiles.slice(0, 20),
|
|
354
|
+
},
|
|
355
|
+
impact: input.impact
|
|
356
|
+
? {
|
|
357
|
+
affectedFiles: input.impact.affectedFiles.length,
|
|
358
|
+
importers: Object.keys(input.impact.importers).length,
|
|
359
|
+
exportedChanges: input.impact.exportedChanges.length,
|
|
360
|
+
}
|
|
361
|
+
: undefined,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Valida/normaliza o `technical-summary.json`; null quando vazio/inválido. */
|
|
366
|
+
export function parseTechnicalSummary(raw: unknown): TechnicalSummary | null {
|
|
367
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
368
|
+
const obj = raw as Record<string, unknown>;
|
|
369
|
+
|
|
370
|
+
const highlights: TechnicalHighlight[] = [];
|
|
371
|
+
if (Array.isArray(obj.highlights)) {
|
|
372
|
+
for (const item of obj.highlights) {
|
|
373
|
+
if (typeof item !== "object" || item === null) continue;
|
|
374
|
+
const entry = item as Record<string, unknown>;
|
|
375
|
+
const area = typeof entry.area === "string" ? entry.area.trim() : "";
|
|
376
|
+
const detail = typeof entry.detail === "string" ? entry.detail.trim() : "";
|
|
377
|
+
if (!area || !detail) continue;
|
|
378
|
+
highlights.push({
|
|
379
|
+
area: truncate(area, 80),
|
|
380
|
+
detail: truncate(detail, MAX_DETAIL),
|
|
381
|
+
});
|
|
382
|
+
if (highlights.length === MAX_HIGHLIGHTS) break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const overview =
|
|
387
|
+
typeof obj.overview === "string" && obj.overview.trim().length > 0
|
|
388
|
+
? truncate(obj.overview, 600)
|
|
389
|
+
: undefined;
|
|
390
|
+
if (highlights.length === 0 && !overview) return null;
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
source: obj.source === "deterministic" ? "deterministic" : "ai",
|
|
394
|
+
headSha: typeof obj.headSha === "string" ? obj.headSha : undefined,
|
|
395
|
+
overview,
|
|
396
|
+
highlights,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -37,6 +37,7 @@ export class AgentFactory {
|
|
|
37
37
|
reporter: this.deps.reporter,
|
|
38
38
|
maxOutputTokens: config.maxOutputTokensFor(context.watchdog.id),
|
|
39
39
|
temperature: config.temperature,
|
|
40
|
+
providerOptions: config.providerOptions,
|
|
40
41
|
timeoutMs: config.timeoutMs,
|
|
41
42
|
ast: this.deps.ast,
|
|
42
43
|
readFile: this.deps.readFile,
|