@payconductor/houndsman 0.13.0 → 0.16.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/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +105 -4
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +6 -1
- package/src/cli/index.ts +5 -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 +278 -16
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +266 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +29 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +310 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +41 -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 +371 -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 +22 -2
- package/src/core/github-review.ts +5 -2
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +621 -34
- package/src/core/hunt/index.ts +40 -1
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +298 -5
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/hunt/review-checks.ts +460 -0
- 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 +18 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +90 -27
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +116 -278
- package/src/core/severity.ts +245 -0
- 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 +17 -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 +51 -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 +55 -10
- package/src/skill/README.md +19 -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 +205 -58
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
- 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 +256 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +710 -2
- 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 +125 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/severity.test.ts +235 -0
- 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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* orçamento sem depender do serviço de classificação.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HoundsmanPriority } from "../config";
|
|
8
9
|
import type { ChurnMap } from "../types";
|
|
9
10
|
|
|
10
11
|
/** Churn total (adições + remoções) de um arquivo; 0 quando ausente. */
|
|
@@ -13,10 +14,66 @@ export function churnOf(filePath: string, churn: ChurnMap | undefined): number {
|
|
|
13
14
|
return entry ? entry.additions + entry.deletions : 0;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
export function
|
|
17
|
+
/** Regra de prioridade do caminho (a primeira que casar vence). */
|
|
18
|
+
export function priorityFor(
|
|
19
|
+
filePath: string,
|
|
20
|
+
priorities: readonly HoundsmanPriority[] | undefined,
|
|
21
|
+
): HoundsmanPriority | undefined {
|
|
22
|
+
for (const rule of priorities ?? []) {
|
|
23
|
+
// Regex global mantém `lastIndex` entre chamadas — zera para não pular match.
|
|
24
|
+
if (rule.pattern.global) rule.pattern.lastIndex = 0;
|
|
25
|
+
if (rule.pattern.test(filePath)) return rule;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Bônus da regra (linhas de churn equivalentes; ausente/inválido = 0). */
|
|
31
|
+
function bonusOf(rule: HoundsmanPriority | undefined): number {
|
|
32
|
+
const bonus = rule?.bonus;
|
|
33
|
+
return bonus !== undefined && Number.isFinite(bonus) ? Math.max(0, bonus) : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Bônus de prioridade do caminho (primeira regra que casar vence). */
|
|
37
|
+
export function priorityBonus(
|
|
38
|
+
filePath: string,
|
|
39
|
+
priorities: readonly HoundsmanPriority[] | undefined,
|
|
40
|
+
): number {
|
|
41
|
+
return bonusOf(priorityFor(filePath, priorities));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Teto de churn da regra (ausente/inválido = sem teto). */
|
|
45
|
+
function churnLimitOf(rule: HoundsmanPriority | undefined): number {
|
|
46
|
+
const limit = rule?.maxRiskFromChurn;
|
|
47
|
+
return limit !== undefined && Number.isFinite(limit) && limit > 0
|
|
48
|
+
? limit
|
|
49
|
+
: Number.POSITIVE_INFINITY;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Risco do arquivo: `min(churn, maxRiskFromChurn) + bonus`.
|
|
54
|
+
*
|
|
55
|
+
* O teto existe para superchurn de caminho de baixa criticidade (frontend que
|
|
56
|
+
* reformatou uma tabela inteira) não competir com churn real de caminho crítico;
|
|
57
|
+
* abaixo do teto, o peso do caminho é o churn natural.
|
|
58
|
+
*/
|
|
59
|
+
export function riskOf(
|
|
60
|
+
filePath: string,
|
|
61
|
+
churn: ChurnMap | undefined,
|
|
62
|
+
priorities?: readonly HoundsmanPriority[],
|
|
63
|
+
): number {
|
|
64
|
+
const rule = priorityFor(filePath, priorities);
|
|
65
|
+
const churnRisk = Math.min(churnOf(filePath, churn), churnLimitOf(rule));
|
|
66
|
+
return churnRisk + bonusOf(rule);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Ordena os arquivos por risco (churn + prioridade; empate pelo caminho). */
|
|
70
|
+
export function sortByRisk(
|
|
71
|
+
files: readonly string[],
|
|
72
|
+
churn?: ChurnMap,
|
|
73
|
+
priorities?: readonly HoundsmanPriority[],
|
|
74
|
+
): string[] {
|
|
18
75
|
return [...files].sort((a, b) => {
|
|
19
|
-
const delta =
|
|
76
|
+
const delta = riskOf(b, churn, priorities) - riskOf(a, churn, priorities);
|
|
20
77
|
return delta !== 0 ? delta : a.localeCompare(b);
|
|
21
78
|
});
|
|
22
79
|
}
|
|
@@ -33,8 +90,9 @@ export function selectByBudget(
|
|
|
33
90
|
files: readonly string[],
|
|
34
91
|
churn: ChurnMap | undefined,
|
|
35
92
|
maxFiles: number,
|
|
93
|
+
priorities?: readonly HoundsmanPriority[],
|
|
36
94
|
): BudgetSelection {
|
|
37
|
-
const ordered = sortByRisk(files, churn);
|
|
95
|
+
const ordered = sortByRisk(files, churn, priorities);
|
|
38
96
|
if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
|
|
39
97
|
return { selected: ordered, overflow: [] };
|
|
40
98
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* consultas usadas pelo orquestrador (arquivos por watchdog, orçamento, churn).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HoundsmanPriority } from "../config";
|
|
8
9
|
import type { ClassificationResult } from "../file-classifier";
|
|
9
10
|
import type { ChurnMap } from "../types";
|
|
10
11
|
import { type BudgetSelection, selectByBudget } from "./budget";
|
|
@@ -72,8 +73,17 @@ export class Classification {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Aplica o orçamento aos arquivos de um watchdog. */
|
|
75
|
-
budgetFor(
|
|
76
|
-
|
|
76
|
+
budgetFor(
|
|
77
|
+
id: string,
|
|
78
|
+
maxFiles: number,
|
|
79
|
+
priorities?: readonly HoundsmanPriority[],
|
|
80
|
+
): BudgetSelection {
|
|
81
|
+
return selectByBudget(
|
|
82
|
+
this.watchdogsFor(id),
|
|
83
|
+
this.data.churn,
|
|
84
|
+
maxFiles,
|
|
85
|
+
priorities,
|
|
86
|
+
);
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
/** Totais de churn do diff. */
|
|
@@ -34,6 +34,10 @@ export function buildCarryover(
|
|
|
34
34
|
message: finding.message,
|
|
35
35
|
action: finding.action,
|
|
36
36
|
evidence: finding.evidence,
|
|
37
|
+
scenario: finding.scenario,
|
|
38
|
+
impact: finding.impact,
|
|
39
|
+
confidence: finding.confidence,
|
|
40
|
+
basis: finding.basis,
|
|
37
41
|
foundAtSha: finding.foundAtSha,
|
|
38
42
|
watchdogs: [...finding.watchdogIds],
|
|
39
43
|
}));
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline do finalize (Passo 2): findings → dedupe → estado de triagem →
|
|
3
|
+
* resumo final (`review-resume.md`).
|
|
4
|
+
*
|
|
5
|
+
* O contexto da run entra no construtor; sem estado ambiente.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { GENERAL_WATCHDOG_ID } from "../builtins";
|
|
9
|
+
import { summarizeBusiness } from "../business-summary";
|
|
10
|
+
import type { HoundsmanConfig } from "../config";
|
|
11
|
+
import { buildStateFromFindings } from "../findings-state";
|
|
12
|
+
import { syncResolvedThreads } from "../github-review";
|
|
13
|
+
import type { JevCandidatesFile } from "../jev";
|
|
14
|
+
import { displayPath, OUTPUT_FORMAT_PATH, REVIEW_RESUME_FILE } from "../paths";
|
|
15
|
+
import { ProjectConfig } from "../project-config";
|
|
16
|
+
import {
|
|
17
|
+
type ManifestAi,
|
|
18
|
+
type ManifestAst,
|
|
19
|
+
type ManifestBusiness,
|
|
20
|
+
ManifestWriter,
|
|
21
|
+
} from "../renderers/manifest-writer";
|
|
22
|
+
import { type ResumeDomain, renderReviewResume } from "../renderers/resume-renderer";
|
|
23
|
+
import { dedupeFindings, type WatchdogReport } from "../report-builder";
|
|
24
|
+
import { writeRunStatus } from "../run-status";
|
|
25
|
+
import { systemClock } from "../services/clock";
|
|
26
|
+
import { anchorReports, severityAnchoringMessages } from "../severity";
|
|
27
|
+
import type { Rule } from "../types";
|
|
28
|
+
import { findWatchdog } from "../watchdog-registry";
|
|
29
|
+
import { mergeCarryover } from "./carryover";
|
|
30
|
+
import type { ReviewContext } from "./review-context";
|
|
31
|
+
import type { ReviewOptions } from "./review-options";
|
|
32
|
+
import type { RunClassification } from "./review-scope";
|
|
33
|
+
import { RunArtifacts } from "./run-artifacts";
|
|
34
|
+
import { shortSha } from "./run-format";
|
|
35
|
+
|
|
36
|
+
/** Aviso no topo do resumo quando a revisão foi incremental (senão vazio). */
|
|
37
|
+
function incrementalNote(classification: RunClassification): string {
|
|
38
|
+
if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
|
|
39
|
+
const carried = classification.carryover?.length ?? 0;
|
|
40
|
+
return (
|
|
41
|
+
`> **Revisão incremental** — desde \`${shortSha(classification.reviewedFrom)}\`; ` +
|
|
42
|
+
`${Object.keys(classification.files).length} arquivo(s) alterado(s), ` +
|
|
43
|
+
`${carried} finding(s) carregado(s) do review anterior.\n\n`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cobertura efetiva de um watchdog: só conta arquivos que estavam no escopo.
|
|
49
|
+
*
|
|
50
|
+
* `reviewedFiles` é auto-declarado pelo subagente e costuma incluir chamadores e
|
|
51
|
+
* arquivos de outros domínios; sem o recorte, a tabela mostrava "25/13" e
|
|
52
|
+
* escondia o que realmente ficou de fora.
|
|
53
|
+
*/
|
|
54
|
+
export function coverageOf(
|
|
55
|
+
reviewedFiles: readonly string[],
|
|
56
|
+
expectedFiles: readonly string[],
|
|
57
|
+
): { reviewed: number; expected: number } {
|
|
58
|
+
const expected = new Set(expectedFiles);
|
|
59
|
+
const reviewed = new Set(reviewedFiles.filter((file) => expected.has(file)));
|
|
60
|
+
return { reviewed: reviewed.size, expected: expected.size };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Arquivos do escopo que não foram revisados nem declarados em `skipped`. */
|
|
64
|
+
export function unaccountedFiles(
|
|
65
|
+
reviewedFiles: readonly string[],
|
|
66
|
+
skippedFiles: readonly string[],
|
|
67
|
+
expectedFiles: readonly string[],
|
|
68
|
+
): string[] {
|
|
69
|
+
const accounted = new Set([...reviewedFiles, ...skippedFiles]);
|
|
70
|
+
return expectedFiles.filter((file) => !accounted.has(file));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Arquivos do escopo declarados em `skipped` (e não revisados). */
|
|
74
|
+
export function skippedInScope(
|
|
75
|
+
reviewedFiles: readonly string[],
|
|
76
|
+
skippedFiles: readonly string[],
|
|
77
|
+
expectedFiles: readonly string[],
|
|
78
|
+
): number {
|
|
79
|
+
const reviewed = new Set(reviewedFiles);
|
|
80
|
+
const expected = new Set(expectedFiles);
|
|
81
|
+
return new Set(
|
|
82
|
+
skippedFiles.filter((file) => expected.has(file) && !reviewed.has(file)),
|
|
83
|
+
).size;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cobertura por domínio (achados × arquivos revisados/esperados) para a tabela
|
|
88
|
+
* do resumo final. Não grava relatório por domínio: os achados vivem na triagem
|
|
89
|
+
* (`findings`), que já é filtrável por watchdog na UI.
|
|
90
|
+
*/
|
|
91
|
+
function summarizeDomains(
|
|
92
|
+
reports: readonly WatchdogReport[],
|
|
93
|
+
classification: RunClassification,
|
|
94
|
+
nameOf: (id: string) => string,
|
|
95
|
+
): ResumeDomain[] {
|
|
96
|
+
return reports
|
|
97
|
+
.map((report) => {
|
|
98
|
+
const expected = classification.watchdogs[report.watchdogId] ?? [];
|
|
99
|
+
const coverage = coverageOf(report.reviewedFiles, expected);
|
|
100
|
+
return {
|
|
101
|
+
id: report.watchdogId,
|
|
102
|
+
name: nameOf(report.watchdogId),
|
|
103
|
+
findings: dedupeFindings([report]).length,
|
|
104
|
+
reviewedFiles: coverage.reviewed,
|
|
105
|
+
expectedFiles: coverage.expected,
|
|
106
|
+
skippedFiles: skippedInScope(
|
|
107
|
+
report.reviewedFiles,
|
|
108
|
+
report.skipped,
|
|
109
|
+
expected,
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
.sort((a, b) => b.findings - a.findings || a.name.localeCompare(b.name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type FinalizeResult = {
|
|
117
|
+
reportPath: string;
|
|
118
|
+
blockers: number;
|
|
119
|
+
attention: number;
|
|
120
|
+
messages: string[];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Passo 2 — consolida os findings no resumo final (`review-resume.md`).
|
|
125
|
+
* Lança se houver findings pendentes (o CLI decide o exit code).
|
|
126
|
+
*/
|
|
127
|
+
export class FinalizePipeline {
|
|
128
|
+
private readonly artifacts: RunArtifacts;
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
private readonly ctx: ReviewContext,
|
|
132
|
+
private readonly config: HoundsmanConfig,
|
|
133
|
+
private readonly opts: ReviewOptions,
|
|
134
|
+
) {
|
|
135
|
+
this.artifacts = new RunArtifacts(ctx.storage, ctx.review, ctx.work);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
run(): FinalizeResult {
|
|
139
|
+
const { ctx, config, opts } = this;
|
|
140
|
+
const branch = ctx.review.branch;
|
|
141
|
+
// `--finalize` só consolida um ciclo preparado (status `reviewing`). Sem esta
|
|
142
|
+
// guarda, um prepare no-op seguido de `--finalize` reescreveria o resumo
|
|
143
|
+
// anterior com a classificação antiga (perdendo a cobertura por domínio).
|
|
144
|
+
// O `ctx.review` é um snapshot: relê o status atual do storage.
|
|
145
|
+
const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
|
|
146
|
+
if (status !== "reviewing") {
|
|
147
|
+
const state = ctx.storage.readFindings(ctx.review.id);
|
|
148
|
+
const present = Object.values(state?.findings ?? {}).filter(
|
|
149
|
+
(finding) => finding.present,
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
reportPath: ctx.storage.location(ctx.review.id),
|
|
153
|
+
blockers: present.filter((f) => f.severity === "blocking").length,
|
|
154
|
+
attention: present.filter((f) => f.severity === "attention").length,
|
|
155
|
+
messages: [
|
|
156
|
+
`Nada a finalizar: nenhum ciclo de revisão pendente (status: ${status ?? "sem status"}).`,
|
|
157
|
+
"Se nada mudou desde a última revisão, o resumo anterior foi mantido; use `--full` para re-revisar.",
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const classification = this.artifacts.classification();
|
|
163
|
+
const activeIds = Object.keys(classification.watchdogs);
|
|
164
|
+
|
|
165
|
+
const messages: string[] = [];
|
|
166
|
+
const ingested = ctx.work.readFindings(activeIds);
|
|
167
|
+
const { pending, warnings } = ingested;
|
|
168
|
+
messages.push(...warnings);
|
|
169
|
+
// Severidade é derivada (regra declarada ou classe de impacto), nunca a escolhida
|
|
170
|
+
// pelo watchdog: o mesmo código não pode sair `blocking` numa run e `attention`
|
|
171
|
+
// na seguinte. Divergências são corrigidas aqui e reportadas abaixo.
|
|
172
|
+
const anchored = anchorReports(ingested.reports, this.declaredRules());
|
|
173
|
+
messages.push(...severityAnchoringMessages(anchored));
|
|
174
|
+
const reports = anchored.reports;
|
|
175
|
+
const jev = this.artifacts.candidates(config);
|
|
176
|
+
const extras = this.artifacts.manifestExtras();
|
|
177
|
+
const business = this.artifacts.persistBusiness();
|
|
178
|
+
const manifestBusiness: ManifestBusiness | undefined = business
|
|
179
|
+
? { source: business.source, ...summarizeBusiness(business) }
|
|
180
|
+
: extras.business;
|
|
181
|
+
this.writeManifest(
|
|
182
|
+
classification,
|
|
183
|
+
activeIds,
|
|
184
|
+
pending,
|
|
185
|
+
jev,
|
|
186
|
+
extras.ast,
|
|
187
|
+
extras.ai,
|
|
188
|
+
manifestBusiness,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (pending.length > 0) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
|
|
194
|
+
`Cada watchdog deve gravar ${displayPath(ctx.work.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const now = systemClock();
|
|
199
|
+
// Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
|
|
200
|
+
const merged = mergeCarryover(
|
|
201
|
+
dedupeFindings(reports),
|
|
202
|
+
classification.carryover ?? [],
|
|
203
|
+
);
|
|
204
|
+
if (classification.mode === "incremental") {
|
|
205
|
+
messages.push(
|
|
206
|
+
`Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Estado de triagem: preserva o que já existia (casando por fingerprint) e
|
|
211
|
+
// anexa o `id` de cada finding aos itens do relatório. A linha é cache —
|
|
212
|
+
// recalculada por `houndsman findings sync` após edições. O commit revisado
|
|
213
|
+
// (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
|
|
214
|
+
const state = buildStateFromFindings(merged, {
|
|
215
|
+
branch,
|
|
216
|
+
readFile: ctx.files.reader(),
|
|
217
|
+
previous: ctx.storage.readFindings(ctx.review.id),
|
|
218
|
+
reviewedPaths: new Set([
|
|
219
|
+
...reports.flatMap((report) => report.reviewedFiles),
|
|
220
|
+
// Arquivo deletado: o achado deixou de existir junto com ele.
|
|
221
|
+
...classification.deleted,
|
|
222
|
+
]),
|
|
223
|
+
now,
|
|
224
|
+
headSha: classification.headSha,
|
|
225
|
+
reviewed: classification.headSha
|
|
226
|
+
? { headSha: classification.headSha, at: now }
|
|
227
|
+
: undefined,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Findings corrigidos entre revisões (ausentes + done) têm a conversa do PR
|
|
231
|
+
// resolvida automaticamente; as já resolvidas no GitHub são puladas. Só
|
|
232
|
+
// toca o `gh` quando há vínculo remoto pendente (fail-open).
|
|
233
|
+
const threadIds = Object.values(state.findings)
|
|
234
|
+
.filter(
|
|
235
|
+
(finding) =>
|
|
236
|
+
!finding.present &&
|
|
237
|
+
finding.execState === "done" &&
|
|
238
|
+
finding.github &&
|
|
239
|
+
finding.github.kind !== "comment" &&
|
|
240
|
+
!finding.github.resolvedAt,
|
|
241
|
+
)
|
|
242
|
+
.map((finding) => finding.id);
|
|
243
|
+
if (threadIds.length > 0) {
|
|
244
|
+
const sync = syncResolvedThreads({ state, ids: threadIds, now });
|
|
245
|
+
if (!sync.available) {
|
|
246
|
+
messages.push(`GitHub: threads não sincronizadas (${sync.reason}).`);
|
|
247
|
+
} else if (sync.checked > 0) {
|
|
248
|
+
const failures =
|
|
249
|
+
sync.failed.length > 0 ? `, ${sync.failed.length} falha(s)` : "";
|
|
250
|
+
messages.push(
|
|
251
|
+
`GitHub: ${sync.resolved.length} thread(s) resolvida(s), ${sync.skipped.length} já resolvida(s)/pulada(s)${failures}.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
ctx.storage.writeFindings(ctx.review.id, state);
|
|
257
|
+
ctx.storage.updateReview(ctx.review.id, {
|
|
258
|
+
base: opts.base,
|
|
259
|
+
head: opts.head,
|
|
260
|
+
headSha: classification.headSha,
|
|
261
|
+
});
|
|
262
|
+
writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
|
|
263
|
+
|
|
264
|
+
this.artifacts.persistContext();
|
|
265
|
+
const context = ctx.work.readContextSummary() ?? this.artifacts.contextSummary();
|
|
266
|
+
const technical =
|
|
267
|
+
this.artifacts.technicalSummary() ??
|
|
268
|
+
this.artifacts.technicalFallback(classification);
|
|
269
|
+
const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
|
|
270
|
+
const domains = summarizeDomains(reports, classification, nameOf);
|
|
271
|
+
// Resumo final — único artefato humano: negócio, técnico, cobertura e
|
|
272
|
+
// conclusão. Os achados ficam na triagem (storage/UI), não duplicados aqui.
|
|
273
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
274
|
+
name: REVIEW_RESUME_FILE,
|
|
275
|
+
kind: "resume",
|
|
276
|
+
content: renderReviewResume({
|
|
277
|
+
branch,
|
|
278
|
+
date: now.slice(0, 10),
|
|
279
|
+
note: incrementalNote(classification),
|
|
280
|
+
context,
|
|
281
|
+
business,
|
|
282
|
+
classification,
|
|
283
|
+
findings: merged,
|
|
284
|
+
technical,
|
|
285
|
+
domains,
|
|
286
|
+
candidates: jev.available ? jev.candidates : undefined,
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const location = ctx.storage.location(ctx.review.id);
|
|
291
|
+
messages.push(
|
|
292
|
+
`Triagem: ${location} — ${Object.keys(state.findings).length} finding(s).`,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const blockers = merged.filter((f) => f.severity === "blocking").length;
|
|
296
|
+
const attention = merged.filter((f) => f.severity === "attention").length;
|
|
297
|
+
messages.push(
|
|
298
|
+
`Resumo: ${location} (${REVIEW_RESUME_FILE}) — ${blockers} bloqueante(s), ${attention} atenção.`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Lacuna de cobertura: arquivos do escopo que ninguém declarou ter revisado.
|
|
302
|
+
const gaps = reports.flatMap((report) => {
|
|
303
|
+
const expected = classification.watchdogs[report.watchdogId] ?? [];
|
|
304
|
+
const missing = unaccountedFiles(
|
|
305
|
+
report.reviewedFiles,
|
|
306
|
+
report.skipped,
|
|
307
|
+
expected,
|
|
308
|
+
);
|
|
309
|
+
if (missing.length === 0) return [];
|
|
310
|
+
return [
|
|
311
|
+
{
|
|
312
|
+
id: report.watchdogId,
|
|
313
|
+
missing,
|
|
314
|
+
coverage: coverageOf(report.reviewedFiles, expected),
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
});
|
|
318
|
+
if (gaps.length > 0) {
|
|
319
|
+
const total = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
|
|
320
|
+
const detail = gaps
|
|
321
|
+
.map((gap) => {
|
|
322
|
+
const sample = gap.missing.slice(0, 3).join(", ");
|
|
323
|
+
const rest =
|
|
324
|
+
gap.missing.length > 3 ? ` (+${gap.missing.length - 3})` : "";
|
|
325
|
+
return `${nameOf(gap.id)} (${gap.coverage.reviewed}/${gap.coverage.expected}): ${sample}${rest}`;
|
|
326
|
+
})
|
|
327
|
+
.join("; ");
|
|
328
|
+
messages.push(
|
|
329
|
+
`Cobertura: ${total} arquivo(s) sem revisão declarada — ${detail}.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// O staging já cumpriu o papel (briefs/findings/contexto estão no storage).
|
|
334
|
+
ctx.work.clean();
|
|
335
|
+
|
|
336
|
+
return { reportPath: location, blockers, attention, messages };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* União das regras declaradas (todos os watchdogs ativos + o piso embutido do
|
|
341
|
+
* `general`). É a fonte da severidade dos findings de regra.
|
|
342
|
+
*/
|
|
343
|
+
private declaredRules(): Rule[] {
|
|
344
|
+
const ids = new Set<string>([...this.config.watchdogIds, GENERAL_WATCHDOG_ID]);
|
|
345
|
+
const rules: Rule[] = [];
|
|
346
|
+
for (const id of ids) {
|
|
347
|
+
const definition = findWatchdog(this.config, id);
|
|
348
|
+
if (definition) rules.push(...definition.rules);
|
|
349
|
+
}
|
|
350
|
+
return rules;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Grava o manifesto da run (delegado ao `ManifestWriter`). */
|
|
354
|
+
private writeManifest(
|
|
355
|
+
classification: RunClassification,
|
|
356
|
+
activeIds: string[],
|
|
357
|
+
pending: string[],
|
|
358
|
+
jev: JevCandidatesFile,
|
|
359
|
+
ast?: ManifestAst,
|
|
360
|
+
ai?: ManifestAi,
|
|
361
|
+
business?: ManifestBusiness,
|
|
362
|
+
): void {
|
|
363
|
+
new ManifestWriter(
|
|
364
|
+
this.ctx.storage,
|
|
365
|
+
this.ctx.review,
|
|
366
|
+
this.ctx.work,
|
|
367
|
+
new ProjectConfig(this.config),
|
|
368
|
+
this.opts,
|
|
369
|
+
).write(classification, activeIds, pending, jev, ast, ai, business);
|
|
370
|
+
}
|
|
371
|
+
}
|