@payconductor/houndsman 0.13.0 → 0.14.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 +6 -1
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/hunt-command.ts +20 -3
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/index.ts +3 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +8 -0
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +246 -12
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +233 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +27 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +243 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +31 -3
- package/src/core/defaults.ts +50 -4
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +347 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +13 -1
- package/src/core/github-review.ts +5 -2
- package/src/core/hunt/hunt-runner.ts +90 -0
- package/src/core/hunt/index.ts +2 -0
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +13 -1
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +8 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +60 -21
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +108 -278
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +16 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +29 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +36 -10
- package/src/skill/README.md +18 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +145 -58
- package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
- package/src/tests/ai-core.test.ts +46 -0
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/compare.test.ts +233 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/hunt-maki.test.ts +58 -0
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +106 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- package/src/skill/example-review.md +0 -66
package/src/core/paths.ts
CHANGED
|
@@ -47,12 +47,15 @@ export const CONTEXT_SUMMARY_FILE = "context-summary.json";
|
|
|
47
47
|
/** Brief do subagente que resume o contexto da PR. */
|
|
48
48
|
export const CONTEXT_BRIEF_FILE = "context-brief.md";
|
|
49
49
|
|
|
50
|
+
/** Modelo de intenção da PR (negócio) — insumo obrigatório do revisor `general`. */
|
|
51
|
+
export const BUSINESS_SUMMARY_FILE = "business-summary.json";
|
|
52
|
+
|
|
53
|
+
/** Brief do passo de intenção (subagente na skill / pré-passe no hunt). */
|
|
54
|
+
export const BUSINESS_BRIEF_FILE = "business-brief.md";
|
|
55
|
+
|
|
50
56
|
/** Relatório da verificação de findings (artefato `verify.md`). */
|
|
51
57
|
export const VERIFY_FILE = "verify.md";
|
|
52
58
|
|
|
53
|
-
/** Relatório consolidado da review (artefato `review.md`). */
|
|
54
|
-
export const REVIEW_FILE = "review.md";
|
|
55
|
-
|
|
56
59
|
/** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
|
|
57
60
|
export const REVIEW_RESUME_FILE = "review-resume.md";
|
|
58
61
|
|
package/src/core/pr-context.ts
CHANGED
|
@@ -203,7 +203,7 @@ export function renderContextBrief(
|
|
|
203
203
|
|
|
204
204
|
lines.push("## Objetivo", "");
|
|
205
205
|
lines.push(
|
|
206
|
-
"Resumir a PR para abrir o
|
|
206
|
+
"Resumir a PR para abrir o resumo final em **Objetivo**,",
|
|
207
207
|
"**Motivação** e **Resumo das alterações** (pt-BR).",
|
|
208
208
|
"",
|
|
209
209
|
);
|
|
@@ -228,9 +228,7 @@ export function renderContextBrief(
|
|
|
228
228
|
lines.push("```", "");
|
|
229
229
|
|
|
230
230
|
lines.push("## Regras", "");
|
|
231
|
-
lines.push(
|
|
232
|
-
"- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
|
|
233
|
-
);
|
|
231
|
+
lines.push("- O resumo abre o resumo final; nos briefs ele é recortado.");
|
|
234
232
|
lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
|
|
235
233
|
lines.push(
|
|
236
234
|
"- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relatório de duração do `prepare` (artefato `prepare-report.json`).
|
|
3
|
+
*
|
|
4
|
+
* O CLI já imprime cada etapa; o artefato permite comparar runs (antes/depois
|
|
5
|
+
* de tuning) sem poluir o `manifest.json`, que é determinístico nos snapshots.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AiUsage } from "./ai/types";
|
|
9
|
+
import type { PrepareStage } from "./orchestrator";
|
|
10
|
+
|
|
11
|
+
/** Duração de uma etapa do prepare. */
|
|
12
|
+
export type PrepareStageTiming = {
|
|
13
|
+
stage: PrepareStage;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Chamada de IA que falhou (timeout, truncamento, schema inválido). */
|
|
18
|
+
export type PrepareAiFailure = {
|
|
19
|
+
feature: string;
|
|
20
|
+
model: string;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
error: string;
|
|
23
|
+
finishReason?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Uso de IA do prepare: tokens das chamadas boas + tentativas/falhas. */
|
|
27
|
+
export type PrepareAiReport = AiUsage & {
|
|
28
|
+
attempts: number;
|
|
29
|
+
failures: readonly PrepareAiFailure[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Entrada do relatório. */
|
|
33
|
+
export type PrepareReportInput = {
|
|
34
|
+
/** Início do prepare (ISO). */
|
|
35
|
+
startedAt: string;
|
|
36
|
+
durationMs: number;
|
|
37
|
+
stages: readonly PrepareStageTiming[];
|
|
38
|
+
/** Uso agregado de IA e falhas (quando configurada). */
|
|
39
|
+
ai?: PrepareAiReport;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Monta o objeto persistido. */
|
|
43
|
+
export function buildPrepareReport(input: PrepareReportInput) {
|
|
44
|
+
return {
|
|
45
|
+
version: 1 as const,
|
|
46
|
+
startedAt: input.startedAt,
|
|
47
|
+
durationMs: input.durationMs,
|
|
48
|
+
stages: input.stages.map((item) => ({ ...item })),
|
|
49
|
+
ai: input.ai
|
|
50
|
+
? {
|
|
51
|
+
...input.ai,
|
|
52
|
+
failures: input.ai.failures.map((failure) => ({ ...failure })),
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Serializa o relatório (JSON indentado + newline). */
|
|
59
|
+
export function renderPrepareReport(input: PrepareReportInput): string {
|
|
60
|
+
return `${JSON.stringify(buildPrepareReport(input), null, 2)}\n`;
|
|
61
|
+
}
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
HoundsmanAst,
|
|
15
15
|
HoundsmanConfig,
|
|
16
16
|
HoundsmanHunt,
|
|
17
|
+
HoundsmanPriority,
|
|
17
18
|
HoundsmanStorage,
|
|
18
19
|
HoundsmanWorktrees,
|
|
19
20
|
} from "./config";
|
|
@@ -105,6 +106,11 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
105
106
|
return this.raw.docMappings;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/** Prioridade por caminho (vazio = churn puro, comportamento antigo). */
|
|
110
|
+
get priorities(): readonly HoundsmanPriority[] {
|
|
111
|
+
return this.raw.priorities ?? [];
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
/** Config de IA (objeto vazio quando ausente). */
|
|
109
115
|
get ai(): HoundsmanAi {
|
|
110
116
|
return this.raw.ai ?? {};
|
|
@@ -148,11 +154,12 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
148
154
|
return value !== undefined && value > 0 ? value : undefined;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
/** Features resolvidas com defaults (`context`/`brief`/`technical` ligadas). */
|
|
157
|
+
/** Features resolvidas com defaults (`context`/`business`/`brief`/`technical` ligadas). */
|
|
152
158
|
get aiFeatures(): Required<HoundsmanAiFeatures> {
|
|
153
159
|
const features = this.ai.features ?? {};
|
|
154
160
|
return {
|
|
155
161
|
context: features.context !== false,
|
|
162
|
+
business: features.business !== false,
|
|
156
163
|
brief: features.brief !== false,
|
|
157
164
|
technical: features.technical !== false,
|
|
158
165
|
classification: features.classification === true,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Propagação mecânica — detecção determinística de mudanças em massa.
|
|
3
|
+
*
|
|
4
|
+
* Cenário típico: um contrato ganha um parâmetro obrigatório e N arquivos
|
|
5
|
+
* adicionam a mesma linha. Revisar N diffs de uma linha é desperdício; o risco
|
|
6
|
+
* está no padrão (um representante) e nos outliers (quem fugiu do padrão).
|
|
7
|
+
*
|
|
8
|
+
* Tudo aqui é função pura sobre dados já coletados no prepare (patch `-U0`,
|
|
9
|
+
* churn, delta/impacto do AST) — não há leitura extra de git nem chamada de IA.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AstAnalysis, AstImpact } from "./ast/types";
|
|
13
|
+
import { churnOf } from "./domain/budget";
|
|
14
|
+
import type { ChurnMap, PropagationCluster, PropagationGap } from "./types";
|
|
15
|
+
|
|
16
|
+
/** Acima disso o arquivo não é "micro-alteração". */
|
|
17
|
+
export const PROPAGATION_MAX_CHURN = 3;
|
|
18
|
+
|
|
19
|
+
/** Mínimo de arquivos com o mesmo padrão para virar cluster. */
|
|
20
|
+
export const PROPAGATION_MIN_FILES = 5;
|
|
21
|
+
|
|
22
|
+
/** Teto de arquivos examinados (proteção para PRs gigantes). */
|
|
23
|
+
export const PROPAGATION_MAX_FILES = 500;
|
|
24
|
+
|
|
25
|
+
/** Outliers listados no brief (os demais são contados). */
|
|
26
|
+
export const PROPAGATION_MAX_OUTLIERS = 8;
|
|
27
|
+
|
|
28
|
+
/** Importadores listados por contrato no brief. */
|
|
29
|
+
export const PROPAGATION_MAX_IMPORTERS = 10;
|
|
30
|
+
|
|
31
|
+
/** Linhas adicionadas do patch (sem cabeçalho), normalizadas. */
|
|
32
|
+
export function addedLines(patch: string | undefined): string[] {
|
|
33
|
+
if (!patch) return [];
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
for (const raw of patch.split(/\r?\n/)) {
|
|
36
|
+
if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
|
|
37
|
+
const normalized = raw.slice(1).trim().replace(/\s+/g, " ");
|
|
38
|
+
if (normalized.length > 0) lines.push(normalized);
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detecta o padrão dominante entre micro-alterações.
|
|
45
|
+
*
|
|
46
|
+
* Agrupa por linha adicionada **exata** (só normaliza espaços): agrupar por
|
|
47
|
+
* identificador esconderia justamente o outlier que precisa de revisão. Sem
|
|
48
|
+
* grupo dominante, devolve `null` e o brief segue como antes.
|
|
49
|
+
*/
|
|
50
|
+
export function detectPropagation(
|
|
51
|
+
files: readonly string[],
|
|
52
|
+
patch: ReadonlyMap<string, string> | undefined,
|
|
53
|
+
churn?: ChurnMap,
|
|
54
|
+
): PropagationCluster | null {
|
|
55
|
+
if (!patch) return null;
|
|
56
|
+
const micro: { file: string; lines: string[] }[] = [];
|
|
57
|
+
for (const file of files.slice(0, PROPAGATION_MAX_FILES)) {
|
|
58
|
+
if (churnOf(file, churn) > PROPAGATION_MAX_CHURN) continue;
|
|
59
|
+
const lines = addedLines(patch.get(file));
|
|
60
|
+
if (lines.length === 0) continue;
|
|
61
|
+
micro.push({ file, lines });
|
|
62
|
+
}
|
|
63
|
+
if (micro.length < PROPAGATION_MIN_FILES) return null;
|
|
64
|
+
|
|
65
|
+
const groups = new Map<string, { file: string; lines: string[] }[]>();
|
|
66
|
+
for (const entry of micro) {
|
|
67
|
+
const key = entry.lines.join("\n");
|
|
68
|
+
const list = groups.get(key);
|
|
69
|
+
if (list) list.push(entry);
|
|
70
|
+
else groups.set(key, [entry]);
|
|
71
|
+
}
|
|
72
|
+
const [patternKey, members] =
|
|
73
|
+
[...groups.entries()].sort(
|
|
74
|
+
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
|
|
75
|
+
)[0] ?? [];
|
|
76
|
+
if (!members || members.length < PROPAGATION_MIN_FILES) return null;
|
|
77
|
+
|
|
78
|
+
const dominant = new Set(members.map((entry) => entry.file));
|
|
79
|
+
const outliers = micro
|
|
80
|
+
.filter((entry) => !dominant.has(entry.file))
|
|
81
|
+
.slice(0, PROPAGATION_MAX_OUTLIERS);
|
|
82
|
+
return {
|
|
83
|
+
lines: (patternKey ?? "").split("\n"),
|
|
84
|
+
files: members.map((entry) => entry.file),
|
|
85
|
+
outliers,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `true` quando o delta mexeu em um contrato exportado (assinatura/export). */
|
|
90
|
+
function changesExportedContract(analysis: AstAnalysis | undefined): boolean {
|
|
91
|
+
const delta = analysis?.delta;
|
|
92
|
+
if (!delta) return false;
|
|
93
|
+
if (delta.changedExports.length > 0) return true;
|
|
94
|
+
if (delta.changedSignatures.some((change) => change.exported)) return true;
|
|
95
|
+
return delta.modifiedSymbols.some(
|
|
96
|
+
(symbol) =>
|
|
97
|
+
symbol.exported &&
|
|
98
|
+
(symbol.kind === "interface" ||
|
|
99
|
+
symbol.kind === "type" ||
|
|
100
|
+
symbol.kind === "enum" ||
|
|
101
|
+
symbol.kind === "class"),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Contratos alterados com importadores que **não** aparecem no diff.
|
|
107
|
+
*
|
|
108
|
+
* É o cheque de "atualizaram 100 e esqueceram 3": o impacto reverso do AST já
|
|
109
|
+
* lista quem importa o arquivo; quem não está no diff vira suspeito no brief.
|
|
110
|
+
*/
|
|
111
|
+
export function detectPropagationGaps(
|
|
112
|
+
files: readonly string[],
|
|
113
|
+
ast: ReadonlyMap<string, AstAnalysis> | undefined,
|
|
114
|
+
impact: AstImpact | undefined,
|
|
115
|
+
changedFiles: ReadonlySet<string>,
|
|
116
|
+
): PropagationGap[] {
|
|
117
|
+
if (!ast || !impact) return [];
|
|
118
|
+
const gaps: PropagationGap[] = [];
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (!changesExportedContract(ast.get(file))) continue;
|
|
121
|
+
const importers = [...new Set(impact.importers[file] ?? [])]
|
|
122
|
+
.filter((importer) => !changedFiles.has(importer))
|
|
123
|
+
.slice(0, PROPAGATION_MAX_IMPORTERS);
|
|
124
|
+
if (importers.length > 0) gaps.push({ file, importers });
|
|
125
|
+
}
|
|
126
|
+
return gaps;
|
|
127
|
+
}
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* `HoundsmanConfig` — não há caminhos de arquivo do projeto na config.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { GENERAL_WATCHDOG_ID } from "./builtins";
|
|
8
9
|
import type { HoundsmanConfig } from "./config";
|
|
9
10
|
import { env } from "./env";
|
|
10
11
|
import { JevQuestionFactory } from "./jev";
|
|
11
|
-
import {
|
|
12
|
+
import { renderReviewResume } from "./renderers/resume-renderer";
|
|
13
|
+
import { sanitizeBranch } from "./report-builder";
|
|
12
14
|
import { hasExplicitPolarity } from "./rule-text";
|
|
13
15
|
import type { WatchdogMeta } from "./types";
|
|
14
16
|
|
|
@@ -63,6 +65,15 @@ function checkFallbacks(config: HoundsmanConfig, warnings: string[]): void {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** O id `general` é reservado: o motor sempre injeta o revisor de fluxo. */
|
|
69
|
+
function checkReservedIds(config: HoundsmanConfig, warnings: string[]): void {
|
|
70
|
+
if (config.watchdogIds.includes(GENERAL_WATCHDOG_ID)) {
|
|
71
|
+
warnings.push(
|
|
72
|
+
`watchdogs['${GENERAL_WATCHDOG_ID}'] usa um id reservado — os metadados são mesclados ao piso de lógica do motor.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
/** Findings emergentes exigem ao menos uma regra de lógica/domínio como piso. */
|
|
67
78
|
function checkEmergent(config: HoundsmanConfig, warnings: string[]): void {
|
|
68
79
|
for (const id of config.watchdogIds) {
|
|
@@ -79,35 +90,61 @@ function checkEmergent(config: HoundsmanConfig, warnings: string[]): void {
|
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Prioridades por caminho: RegExp, valores finitos (`bonus` ≥ 0, teto > 0) e ao
|
|
95
|
+
* menos um efeito — uma regra sem `bonus` nem `maxRiskFromChurn` é inócua.
|
|
96
|
+
*/
|
|
97
|
+
function checkPriorities(config: HoundsmanConfig, problems: string[]): void {
|
|
98
|
+
for (const [index, rule] of (config.priorities ?? []).entries()) {
|
|
99
|
+
if (!(rule.pattern instanceof RegExp)) {
|
|
100
|
+
problems.push(`priorities[${index}].pattern precisa ser uma RegExp`);
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
rule.bonus !== undefined &&
|
|
104
|
+
(!Number.isFinite(rule.bonus) || rule.bonus < 0)
|
|
105
|
+
) {
|
|
106
|
+
problems.push(`priorities[${index}].bonus precisa ser número ≥ 0`);
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
rule.maxRiskFromChurn !== undefined &&
|
|
110
|
+
(!Number.isFinite(rule.maxRiskFromChurn) || rule.maxRiskFromChurn <= 0)
|
|
111
|
+
) {
|
|
112
|
+
problems.push(`priorities[${index}].maxRiskFromChurn precisa ser número > 0`);
|
|
113
|
+
}
|
|
114
|
+
if (rule.bonus === undefined && rule.maxRiskFromChurn === undefined) {
|
|
115
|
+
problems.push(
|
|
116
|
+
`priorities[${index}] não tem bonus nem maxRiskFromChurn (regra inócua)`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
82
122
|
/** Smoke test dos helpers críticos de renderização. */
|
|
83
|
-
function checkRendering(
|
|
123
|
+
function checkRendering(problems: string[]): void {
|
|
84
124
|
if (sanitizeBranch("feature/x") !== "feature-x") {
|
|
85
125
|
problems.push("sanitizeBranch não está normalizando branches");
|
|
86
126
|
}
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
skipped: [],
|
|
127
|
+
const base = { branch: "main", date: "2026-01-01" } as const;
|
|
128
|
+
const empty = renderReviewResume({
|
|
129
|
+
...base,
|
|
130
|
+
classification: { files: {}, watchdogs: {} },
|
|
92
131
|
findings: [],
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
problems.push("renderReport não marcou status aprovado sem achados");
|
|
132
|
+
});
|
|
133
|
+
if (!empty.includes("✅ Aprovado")) {
|
|
134
|
+
problems.push("renderReviewResume não marcou status aprovado sem achados");
|
|
97
135
|
}
|
|
98
|
-
const withContext =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
[],
|
|
102
|
-
{
|
|
103
|
-
{
|
|
136
|
+
const withContext = renderReviewResume({
|
|
137
|
+
...base,
|
|
138
|
+
classification: { files: {}, watchdogs: {} },
|
|
139
|
+
findings: [],
|
|
140
|
+
context: {
|
|
104
141
|
available: true,
|
|
105
142
|
objective: "Entregar X",
|
|
106
143
|
changeSummary: ["Faz A"],
|
|
107
144
|
},
|
|
108
|
-
);
|
|
109
|
-
if (!withContext.includes("
|
|
110
|
-
problems.push("
|
|
145
|
+
});
|
|
146
|
+
if (!withContext.includes("Entregar X")) {
|
|
147
|
+
problems.push("renderReviewResume não incluiu o contexto da PR");
|
|
111
148
|
}
|
|
112
149
|
}
|
|
113
150
|
|
|
@@ -331,12 +368,14 @@ export function checkRegistry(config: HoundsmanConfig): {
|
|
|
331
368
|
checkDefinitions(config, problems);
|
|
332
369
|
checkClassification(config, problems);
|
|
333
370
|
checkFallbacks(config, warnings);
|
|
371
|
+
checkReservedIds(config, warnings);
|
|
334
372
|
checkEmergent(config, warnings);
|
|
335
|
-
checkRendering(
|
|
373
|
+
checkRendering(problems);
|
|
336
374
|
checkRulePolarity(config, warnings);
|
|
337
375
|
checkRuleDuplication(config, warnings);
|
|
338
376
|
checkMetadataPrecision(config, warnings);
|
|
339
377
|
checkBudgets(config, warnings);
|
|
378
|
+
checkPriorities(config, problems);
|
|
340
379
|
checkAstConfig(config, problems, warnings);
|
|
341
380
|
checkAiConfig(config, problems, warnings);
|
|
342
381
|
const jevQuestions = checkJevSpecs(config, problems);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AiUsage } from "../ai/types";
|
|
10
|
+
import type { BusinessSummary } from "../business-summary";
|
|
10
11
|
import { Classification } from "../domain/classification";
|
|
11
12
|
import type { ClassificationResult } from "../file-classifier";
|
|
12
13
|
import type { JevCandidatesFile } from "../jev";
|
|
@@ -44,6 +45,16 @@ export type ManifestAi = {
|
|
|
44
45
|
usage: AiUsage;
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
/** Resumo do modelo de intenção no manifesto. */
|
|
49
|
+
export type ManifestBusiness = {
|
|
50
|
+
source: BusinessSummary["source"];
|
|
51
|
+
model?: string;
|
|
52
|
+
flows: number;
|
|
53
|
+
invariants: number;
|
|
54
|
+
contracts: number;
|
|
55
|
+
states: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
47
58
|
/** Classificação enriquecida gravada pelo prepare (modo + incremental). */
|
|
48
59
|
export type ManifestClassification = ClassificationResult & {
|
|
49
60
|
headSha?: string;
|
|
@@ -81,6 +92,7 @@ export class ManifestWriter {
|
|
|
81
92
|
jev: JevCandidatesFile,
|
|
82
93
|
ast?: ManifestAst,
|
|
83
94
|
ai?: ManifestAi,
|
|
95
|
+
business?: ManifestBusiness,
|
|
84
96
|
): void {
|
|
85
97
|
const view = Classification.from(classification);
|
|
86
98
|
const maxFilesPerBrief = this.project.maxFilesPerBrief;
|
|
@@ -125,6 +137,7 @@ export class ManifestWriter {
|
|
|
125
137
|
context: summary
|
|
126
138
|
? { summarized: true, source: summary.source ?? null }
|
|
127
139
|
: { summarized: false, source: null },
|
|
140
|
+
business: business ?? null,
|
|
128
141
|
jev: jev.available
|
|
129
142
|
? {
|
|
130
143
|
available: true,
|
|
@@ -7,12 +7,25 @@
|
|
|
7
7
|
* `RESUME_MAX_CHARS`.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { type BusinessSummary, renderBusinessLines } from "../business-summary";
|
|
11
|
+
import type { JevCandidate } from "../jev";
|
|
10
12
|
import type { ContextSummary, MergedFinding } from "../report-builder";
|
|
11
13
|
import { systemClock } from "../services/clock";
|
|
12
14
|
import type { TechnicalSummary } from "../technical-summary";
|
|
13
15
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Teto de caracteres do artefato (inclui cabeçalho e quebras de linha).
|
|
18
|
+
*
|
|
19
|
+
* As tabelas e a conclusão têm orçamento reservado — só o texto narrativo
|
|
20
|
+
* (negócio/técnico) é cortado quando o resumo cresce.
|
|
21
|
+
*/
|
|
22
|
+
export const RESUME_MAX_CHARS = 8000;
|
|
23
|
+
|
|
24
|
+
/** Fração do orçamento narrativo destinada ao resumo de negócio. */
|
|
25
|
+
const BUSINESS_SHARE = 0.6;
|
|
26
|
+
|
|
27
|
+
/** Piso do orçamento narrativo mesmo em PRs com muitos domínios. */
|
|
28
|
+
const MIN_NARRATIVE_CHARS = 1200;
|
|
16
29
|
|
|
17
30
|
/** Dados de classificação usados no resumo. */
|
|
18
31
|
export type ResumeClassification = {
|
|
@@ -20,15 +33,34 @@ export type ResumeClassification = {
|
|
|
20
33
|
watchdogs: Record<string, string[]>;
|
|
21
34
|
};
|
|
22
35
|
|
|
36
|
+
/** Cobertura de um domínio (watchdog) na revisão. */
|
|
37
|
+
export type ResumeDomain = {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
findings: number;
|
|
41
|
+
reviewedFiles: number;
|
|
42
|
+
expectedFiles: number;
|
|
43
|
+
/** Arquivos do escopo declarados em `skipped` (não revisados por decisão). */
|
|
44
|
+
skippedFiles?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
23
47
|
export type ReviewResumeInput = {
|
|
24
48
|
branch: string;
|
|
25
49
|
/** `YYYY-MM-DD`; default: data corrente do sistema. */
|
|
26
50
|
date?: string;
|
|
27
51
|
context?: ContextSummary | null;
|
|
52
|
+
/** Modelo de intenção da PR (negócio). */
|
|
53
|
+
business?: BusinessSummary | null;
|
|
28
54
|
classification: ResumeClassification;
|
|
29
55
|
findings: readonly MergedFinding[];
|
|
30
56
|
/** Pontos técnicos estruturais (não achados). */
|
|
31
57
|
technical?: TechnicalSummary | null;
|
|
58
|
+
/** Cobertura por domínio (arquivos revisados × esperados). */
|
|
59
|
+
domains?: readonly ResumeDomain[];
|
|
60
|
+
/** Candidatos da pré-análise que nenhum watchdog confirmou. */
|
|
61
|
+
candidates?: readonly JevCandidate[];
|
|
62
|
+
/** Aviso opcional no topo (ex.: revisão incremental). */
|
|
63
|
+
note?: string;
|
|
32
64
|
};
|
|
33
65
|
|
|
34
66
|
const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
|
|
@@ -47,8 +79,28 @@ function plural(count: number, singular: string, plural: string): string {
|
|
|
47
79
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
48
80
|
}
|
|
49
81
|
|
|
50
|
-
/** Resumo de negócio:
|
|
82
|
+
/** Resumo de negócio: modelo de intenção; sem ele, contexto da PR/branch. */
|
|
51
83
|
function businessSection(input: ReviewResumeInput): string[] {
|
|
84
|
+
const lines = ["## Resumo de negócio", ""];
|
|
85
|
+
const business = input.business;
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
business &&
|
|
89
|
+
(business.intent || business.flows.length > 0 || business.invariants.length > 0)
|
|
90
|
+
) {
|
|
91
|
+
if (business.intent) lines.push(business.intent, "");
|
|
92
|
+
else if (input.context?.objective) lines.push(input.context.objective, "");
|
|
93
|
+
const rendered = renderBusinessLines(business).filter(
|
|
94
|
+
(line) => !line.startsWith("**Intenção:**"),
|
|
95
|
+
);
|
|
96
|
+
while (rendered[0] === "") rendered.shift();
|
|
97
|
+
if (rendered.length > 0) lines.push(...rendered, "");
|
|
98
|
+
if (input.context?.motivation) {
|
|
99
|
+
lines.push(`**Por quê:** ${truncate(input.context.motivation, 480)}`, "");
|
|
100
|
+
}
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
const context = input.context;
|
|
53
105
|
const hasContext =
|
|
54
106
|
context?.available === true &&
|
|
@@ -57,7 +109,6 @@ function businessSection(input: ReviewResumeInput): string[] {
|
|
|
57
109
|
context.motivation ||
|
|
58
110
|
(context.changeSummary && context.changeSummary.length > 0),
|
|
59
111
|
);
|
|
60
|
-
const lines = ["## Resumo de negócio", ""];
|
|
61
112
|
|
|
62
113
|
if (!hasContext) {
|
|
63
114
|
const domainCount = Object.keys(input.classification.watchdogs).length;
|
|
@@ -107,9 +158,78 @@ function clamp(markdown: string, max: number): string {
|
|
|
107
158
|
return `${(cut > 0 ? head.slice(0, cut) : head).trimEnd()}\n…`;
|
|
108
159
|
}
|
|
109
160
|
|
|
161
|
+
/** Conclusão com os totais por severidade. */
|
|
162
|
+
function conclusionSection(findings: readonly MergedFinding[]): string[] {
|
|
163
|
+
const counts = {
|
|
164
|
+
blocking: findings.filter((f) => f.severity === "blocking").length,
|
|
165
|
+
attention: findings.filter((f) => f.severity === "attention").length,
|
|
166
|
+
suggestion: findings.filter((f) => f.severity === "suggestion").length,
|
|
167
|
+
nit: findings.filter((f) => f.severity === "nit").length,
|
|
168
|
+
};
|
|
169
|
+
return [
|
|
170
|
+
"## Conclusão",
|
|
171
|
+
"",
|
|
172
|
+
`**Resumo:** ${counts.blocking} bloqueantes · ${counts.attention} atenção · ${counts.suggestion} sugestões · ${counts.nit} nits.`,
|
|
173
|
+
"",
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Cobertura por domínio: revisados, declarados fora do recorte e lacunas. */
|
|
178
|
+
function coverageSection(domains: readonly ResumeDomain[]): string[] {
|
|
179
|
+
if (domains.length === 0) return [];
|
|
180
|
+
const lines = [
|
|
181
|
+
"## Cobertura por domínio",
|
|
182
|
+
"",
|
|
183
|
+
"| Domínio | Achados | Revisados | Fora do recorte | Faltam |",
|
|
184
|
+
"|---|---|---|---|---|",
|
|
185
|
+
];
|
|
186
|
+
for (const domain of domains) {
|
|
187
|
+
const skipped = domain.skippedFiles ?? 0;
|
|
188
|
+
const missing = Math.max(
|
|
189
|
+
0,
|
|
190
|
+
domain.expectedFiles - domain.reviewedFiles - skipped,
|
|
191
|
+
);
|
|
192
|
+
lines.push(
|
|
193
|
+
`| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | ${skipped > 0 ? skipped : "—"} | ${missing > 0 ? missing : "—"} |`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
lines.push(
|
|
197
|
+
"",
|
|
198
|
+
"> `Faltam` = arquivos do escopo sem revisão e sem declaração em `skipped`.",
|
|
199
|
+
"",
|
|
200
|
+
);
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Candidatos da pré-análise que nenhum watchdog confirmou (não bloqueiam). */
|
|
205
|
+
function candidatesSection(
|
|
206
|
+
candidates: readonly JevCandidate[],
|
|
207
|
+
findings: readonly MergedFinding[],
|
|
208
|
+
): string[] {
|
|
209
|
+
if (candidates.length === 0) return [];
|
|
210
|
+
const confirmed = new Set(findings.map((f) => `${f.file}|${f.rule}`));
|
|
211
|
+
const pending = candidates.filter((c) => !confirmed.has(`${c.file}|${c.rule}`));
|
|
212
|
+
if (pending.length === 0) return [];
|
|
213
|
+
const lines = [
|
|
214
|
+
`## Candidatos não verificados (${pending.length})`,
|
|
215
|
+
"",
|
|
216
|
+
"> Indícios da pré-análise que nenhum watchdog confirmou. Não bloqueiam.",
|
|
217
|
+
"",
|
|
218
|
+
];
|
|
219
|
+
for (const candidate of pending.slice(0, 8)) {
|
|
220
|
+
lines.push(
|
|
221
|
+
`- \`${candidate.file}\` · ${candidate.severity} · ${truncate(candidate.rule, 160)}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (pending.length > 8) lines.push(`- … +${pending.length - 8}`);
|
|
225
|
+
lines.push("");
|
|
226
|
+
return lines;
|
|
227
|
+
}
|
|
228
|
+
|
|
110
229
|
/**
|
|
111
230
|
* Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
|
|
112
|
-
* nasce do contexto já disponível
|
|
231
|
+
* nasce do contexto já disponível, do modelo de intenção, dos pontos técnicos
|
|
232
|
+
* e da cobertura por domínio. `review-resume.md` é o único artefato humano.
|
|
113
233
|
*/
|
|
114
234
|
export function renderReviewResume(input: ReviewResumeInput): string {
|
|
115
235
|
const blockers = input.findings.filter((f) => f.severity === "blocking").length;
|
|
@@ -121,14 +241,34 @@ export function renderReviewResume(input: ReviewResumeInput): string {
|
|
|
121
241
|
? STATUS_BY_SEVERITY.attention
|
|
122
242
|
: STATUS_BY_SEVERITY.clean;
|
|
123
243
|
|
|
124
|
-
const
|
|
244
|
+
const header = [
|
|
125
245
|
`# Resumo da revisão — ${input.branch}`,
|
|
126
246
|
"",
|
|
127
247
|
`**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
|
|
128
|
-
"",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
];
|
|
248
|
+
...(input.note?.trim() ? ["", input.note.trim()] : []),
|
|
249
|
+
].join("\n");
|
|
250
|
+
|
|
251
|
+
const coverage = coverageSection(input.domains ?? []).join("\n");
|
|
252
|
+
const candidates = candidatesSection(input.candidates ?? [], input.findings).join(
|
|
253
|
+
"\n",
|
|
254
|
+
);
|
|
255
|
+
const conclusion = conclusionSection(input.findings).join("\n");
|
|
256
|
+
|
|
257
|
+
// Tabelas e conclusão primeiro: o que sobra é o orçamento do texto narrativo.
|
|
258
|
+
const reserved = [header, coverage, candidates, conclusion]
|
|
259
|
+
.filter((part) => part.length > 0)
|
|
260
|
+
.join("\n\n").length;
|
|
261
|
+
const budget = Math.max(MIN_NARRATIVE_CHARS, RESUME_MAX_CHARS - reserved);
|
|
262
|
+
const businessMax = Math.floor(budget * BUSINESS_SHARE);
|
|
263
|
+
const business = clamp(businessSection(input).join("\n"), businessMax);
|
|
264
|
+
const technical = clamp(
|
|
265
|
+
technicalSection(input.technical).join("\n"),
|
|
266
|
+
budget - businessMax,
|
|
267
|
+
);
|
|
132
268
|
|
|
133
|
-
|
|
269
|
+
const body = [header, business, technical, coverage, candidates, conclusion]
|
|
270
|
+
.map((part) => part.trim())
|
|
271
|
+
.filter((part) => part.length > 0)
|
|
272
|
+
.join("\n\n");
|
|
273
|
+
return `${body}\n`;
|
|
134
274
|
}
|