@payconductor/houndsman 0.1.4 → 0.3.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/cli/arg-parser.ts +8 -0
- package/src/cli/commands/command.ts +23 -0
- package/src/cli/commands/context-command.ts +38 -0
- package/src/cli/commands/doctor-command.ts +55 -0
- package/src/cli/commands/findings-command.ts +28 -0
- package/src/cli/commands/help-command.ts +13 -0
- package/src/cli/commands/registry.ts +34 -0
- package/src/cli/commands/review-command.ts +22 -0
- package/src/cli/commands/server-command.ts +37 -0
- package/src/cli/commands/setup-command.ts +34 -0
- package/src/cli/commands/watchdog-command.ts +34 -0
- package/src/cli/help.ts +43 -0
- package/src/cli/index.ts +62 -260
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/findings.ts +163 -147
- package/src/core/commands/review.ts +18 -35
- package/src/core/commands/scaffold.ts +174 -64
- package/src/core/commands/setup.ts +16 -11
- package/src/core/commands/update.ts +7 -7
- package/src/core/commands/watchdog.ts +8 -80
- package/src/core/config.ts +9 -19
- package/src/core/defaults.ts +20 -0
- package/src/core/diff-source.ts +1 -13
- package/src/core/doc-discoverer.ts +25 -15
- package/src/core/domain/budget.ts +42 -0
- package/src/core/domain/classification.ts +94 -0
- package/src/core/domain/diff.ts +53 -0
- package/src/core/domain/watchdog.ts +96 -0
- package/src/core/engine/carryover.ts +67 -0
- package/src/core/engine/houndsman-engine.ts +51 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +51 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +189 -38
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +423 -0
- package/src/core/orchestrator.ts +116 -337
- package/src/core/paths.ts +9 -0
- package/src/core/pr-context.ts +28 -54
- package/src/core/project-config.ts +57 -0
- package/src/core/renderers/batch-presenter.ts +40 -0
- package/src/core/renderers/brief-renderer.ts +23 -0
- package/src/core/renderers/findings-presenter.ts +74 -0
- package/src/core/renderers/manifest-writer.ts +112 -0
- package/src/core/renderers/report-renderer.ts +66 -0
- package/src/core/renderers/verify-presenter.ts +50 -0
- package/src/core/renderers/watchdog-presenter.ts +100 -0
- package/src/core/report-builder.ts +12 -7
- package/src/core/run-layout.ts +108 -0
- package/src/core/run-status.ts +6 -11
- package/src/core/services/clock.ts +12 -0
- package/src/core/services/file-classifier.ts +34 -0
- package/src/core/services/fix-batch-builder.ts +13 -0
- package/src/core/services/gh-client.ts +54 -0
- package/src/core/services/git-repository.ts +123 -0
- package/src/core/services/project-files.ts +79 -0
- package/src/core/services/verifier.ts +58 -0
- package/src/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +31 -1
- package/src/server/client/App.tsx +174 -49
- package/src/server/client/ArtifactsPanel.tsx +2 -2
- package/src/server/client/AutoRefresh.tsx +38 -0
- package/src/server/client/Filters.tsx +72 -53
- package/src/server/client/FindingCard.tsx +212 -71
- package/src/server/client/FindingsList.tsx +8 -6
- package/src/server/client/RunList.tsx +30 -7
- package/src/server/client/SubmitModal.tsx +418 -0
- package/src/server/client/SummaryBar.tsx +65 -12
- package/src/server/client/constants.ts +9 -12
- package/src/server/client/format.ts +40 -0
- package/src/server/client/icons.tsx +143 -0
- package/src/server/client/types.ts +69 -0
- package/src/server/index.ts +316 -222
- package/src/server/run-repository.ts +291 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
- package/src/tests/baseline.test.ts +513 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/infrastructure.test.ts +210 -0
- package/src/tests/server.test.ts +117 -0
package/src/core/paths.ts
CHANGED
|
@@ -37,3 +37,12 @@ export const RUN_STATUS_FILE = "run.json";
|
|
|
37
37
|
|
|
38
38
|
/** Relatório da verificação de findings, dentro da pasta da run (`<slug>/verify.md`). */
|
|
39
39
|
export const VERIFY_FILE = "verify.md";
|
|
40
|
+
|
|
41
|
+
/** Contexto bruto da PR (`gh pr view`), dentro da pasta da run. */
|
|
42
|
+
export const CONTEXT_FILE = "context.json";
|
|
43
|
+
|
|
44
|
+
/** Resumo do contexto gravado pelo subagente, dentro da pasta da run. */
|
|
45
|
+
export const CONTEXT_SUMMARY_FILE = "context-summary.json";
|
|
46
|
+
|
|
47
|
+
/** Brief do subagente que resume o contexto da PR. */
|
|
48
|
+
export const CONTEXT_BRIEF_FILE = "context-brief.md";
|
package/src/core/pr-context.ts
CHANGED
|
@@ -14,16 +14,21 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
-
import { join, relative } from "node:path";
|
|
18
17
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
CONTEXT_BRIEF_FILE,
|
|
19
|
+
CONTEXT_FILE,
|
|
20
|
+
CONTEXT_SUMMARY_FILE,
|
|
21
|
+
relFromRoot,
|
|
22
|
+
} from "./paths";
|
|
23
|
+
import type { ContextSummary } from "./report-builder";
|
|
24
|
+
import { parseContextSummary } from "./report-builder";
|
|
25
|
+
import { type ContextPaths, RunLayout } from "./run-layout";
|
|
26
|
+
import { systemClock } from "./services/clock";
|
|
27
|
+
import { defaultGhRunner, GhClient, type GhRunner } from "./services/gh-client";
|
|
28
|
+
import { GitRepository } from "./services/git-repository";
|
|
29
|
+
|
|
30
|
+
export { CONTEXT_BRIEF_FILE, CONTEXT_FILE, CONTEXT_SUMMARY_FILE };
|
|
31
|
+
export type { ContextPaths };
|
|
27
32
|
|
|
28
33
|
const GH_JSON_FIELDS =
|
|
29
34
|
"number,title,body,url,baseRefName,headRefName,author,commits,files";
|
|
@@ -54,55 +59,20 @@ export type PrContextRaw = {
|
|
|
54
59
|
stats?: { files: number; additions: number; deletions: number };
|
|
55
60
|
};
|
|
56
61
|
|
|
57
|
-
export type ContextPaths = {
|
|
58
|
-
dir: string;
|
|
59
|
-
context: string;
|
|
60
|
-
summary: string;
|
|
61
|
-
brief: string;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
62
|
/** Caminhos dos artefatos de contexto para uma branch. */
|
|
65
63
|
export function contextPaths(dir: string, branch: string): ContextPaths {
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
dir: dirPath,
|
|
69
|
-
context: join(dirPath, CONTEXT_FILE),
|
|
70
|
-
summary: join(dirPath, CONTEXT_SUMMARY_FILE),
|
|
71
|
-
brief: join(dirPath, CONTEXT_BRIEF_FILE),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Caminho relativo à raiz, sempre com `/` (portável/legível). */
|
|
76
|
-
function relFromRoot(absPath: string): string {
|
|
77
|
-
return relative(process.cwd(), absPath).replace(/\\/g, "/");
|
|
64
|
+
return new RunLayout(dir, branch).contextPaths;
|
|
78
65
|
}
|
|
79
66
|
|
|
80
|
-
/** Detecta a branch atual;
|
|
67
|
+
/** Detecta a branch atual; lança se não houver (o CLI decide o exit). */
|
|
81
68
|
export function currentBranch(): string {
|
|
82
|
-
const
|
|
83
|
-
stdout: "pipe",
|
|
84
|
-
stderr: "pipe",
|
|
85
|
-
});
|
|
86
|
-
const branch = new TextDecoder().decode(result.stdout).trim();
|
|
69
|
+
const branch = new GitRepository().currentBranch();
|
|
87
70
|
if (!branch) {
|
|
88
|
-
|
|
89
|
-
process.exit(1);
|
|
71
|
+
throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
|
|
90
72
|
}
|
|
91
73
|
return branch;
|
|
92
74
|
}
|
|
93
75
|
|
|
94
|
-
function runGh(args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
95
|
-
const result = Bun.spawnSync(["gh", ...args], {
|
|
96
|
-
stdout: "pipe",
|
|
97
|
-
stderr: "pipe",
|
|
98
|
-
});
|
|
99
|
-
return {
|
|
100
|
-
ok: result.exitCode === 0,
|
|
101
|
-
stdout: new TextDecoder().decode(result.stdout).trim(),
|
|
102
|
-
stderr: new TextDecoder().decode(result.stderr).trim(),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
76
|
type GhPrView = {
|
|
107
77
|
number?: number;
|
|
108
78
|
title?: string;
|
|
@@ -120,19 +90,23 @@ function unavailable(reason: ContextUnavailableReason, detail: string): PrContex
|
|
|
120
90
|
available: false,
|
|
121
91
|
reason,
|
|
122
92
|
detail,
|
|
123
|
-
collectedAt:
|
|
93
|
+
collectedAt: systemClock(),
|
|
124
94
|
};
|
|
125
95
|
}
|
|
126
96
|
|
|
127
97
|
/** Lê a PR da branch via `gh` e devolve o payload bruto (nunca lança). */
|
|
128
|
-
export function collectPrContext(
|
|
129
|
-
|
|
98
|
+
export function collectPrContext(
|
|
99
|
+
branch: string,
|
|
100
|
+
runner: GhRunner = defaultGhRunner,
|
|
101
|
+
): PrContextRaw {
|
|
102
|
+
const collectedAt = systemClock();
|
|
103
|
+
const gh = new GhClient(runner);
|
|
130
104
|
|
|
131
|
-
if (!
|
|
105
|
+
if (!gh.isAvailable()) {
|
|
132
106
|
return unavailable("gh-missing", "GitHub CLI (gh) não encontrado no PATH.");
|
|
133
107
|
}
|
|
134
108
|
|
|
135
|
-
const auth =
|
|
109
|
+
const auth = gh.run(["auth", "status"]);
|
|
136
110
|
if (!auth.ok) {
|
|
137
111
|
return unavailable(
|
|
138
112
|
"gh-unauthenticated",
|
|
@@ -140,7 +114,7 @@ export function collectPrContext(branch: string): PrContextRaw {
|
|
|
140
114
|
);
|
|
141
115
|
}
|
|
142
116
|
|
|
143
|
-
const view =
|
|
117
|
+
const view = gh.run(["pr", "view", branch, "--json", GH_JSON_FIELDS]);
|
|
144
118
|
if (!view.ok) {
|
|
145
119
|
const noPr = /no pull requests? found|not found|could not find/i.test(
|
|
146
120
|
view.stderr,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuração do projeto (domínio) — resolve defaults e expõe o registry.
|
|
3
|
+
*
|
|
4
|
+
* Evita o "data clump" de passar `HoundsmanConfig` para dezenas de funções:
|
|
5
|
+
* quem precisa de `artifactDir`/orçamento/concorrência/watchdogs pede aqui.
|
|
6
|
+
* O contrato do consumidor (`houndsman.config.ts`) não muda.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HoundsmanConfig } from "./config";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_ALWAYS_INCLUDE_DOCS,
|
|
12
|
+
DEFAULT_JEV_CONCURRENCY,
|
|
13
|
+
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
14
|
+
} from "./defaults";
|
|
15
|
+
import { ARTIFACT_DIR } from "./paths";
|
|
16
|
+
import { WatchdogRegistry } from "./watchdog-registry";
|
|
17
|
+
|
|
18
|
+
/** Configuração resolvida do projeto. */
|
|
19
|
+
export class ProjectConfig<TId extends string = string> {
|
|
20
|
+
readonly raw: HoundsmanConfig<TId>;
|
|
21
|
+
readonly watchdogs: WatchdogRegistry<TId>;
|
|
22
|
+
|
|
23
|
+
constructor(raw: HoundsmanConfig<TId>) {
|
|
24
|
+
this.raw = raw;
|
|
25
|
+
this.watchdogs = new WatchdogRegistry(raw);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get projectName(): string {
|
|
29
|
+
return this.raw.projectName;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get artifactDir(): string {
|
|
33
|
+
return this.raw.artifactDir ?? ARTIFACT_DIR;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get maxFilesPerBrief(): number {
|
|
37
|
+
const value = this.raw.budgets?.maxFilesPerBrief;
|
|
38
|
+
return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get jevConcurrency(): number {
|
|
42
|
+
const value = this.raw.concurrency?.jev;
|
|
43
|
+
return value !== undefined && value > 0 ? value : DEFAULT_JEV_CONCURRENCY;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get alwaysIncludeDocs(): readonly string[] {
|
|
47
|
+
return this.raw.alwaysIncludeDocs ?? DEFAULT_ALWAYS_INCLUDE_DOCS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get exclusions(): readonly RegExp[] {
|
|
51
|
+
return this.raw.exclusions;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get docMappings(): readonly (readonly [RegExp, readonly string[]])[] {
|
|
55
|
+
return this.raw.docMappings;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apresentação do lote de correção (biblioteca) — markdown do `findings batch`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FixBatchResult } from "../fix-batch";
|
|
6
|
+
import { FindingsPresenter } from "./findings-presenter";
|
|
7
|
+
|
|
8
|
+
/** Renderiza o grupo atual paginado + os outros grupos disponíveis. */
|
|
9
|
+
export class BatchPresenter {
|
|
10
|
+
private readonly findings = new FindingsPresenter();
|
|
11
|
+
|
|
12
|
+
render(result: FixBatchResult): string {
|
|
13
|
+
const { dimension, current, others, totals } = result;
|
|
14
|
+
if (totals.queue === 0) return "Nenhum finding pendente de correção.";
|
|
15
|
+
|
|
16
|
+
const lines = [
|
|
17
|
+
`# Lote de correção — agrupado por ${dimension} (${totals.groups} grupo(s))`,
|
|
18
|
+
"",
|
|
19
|
+
`## Grupo atual — ${current.key} (${current.total} finding(s), mostrando ${current.returned})`,
|
|
20
|
+
];
|
|
21
|
+
for (const finding of current.findings)
|
|
22
|
+
lines.push(...this.findings.line(finding));
|
|
23
|
+
if (current.remaining > 0) {
|
|
24
|
+
lines.push("", `_… +${current.remaining} neste grupo (use \`--offset\`)._`);
|
|
25
|
+
}
|
|
26
|
+
if (others.length > 0) {
|
|
27
|
+
lines.push("", "## Outros grupos disponíveis");
|
|
28
|
+
for (const group of others) {
|
|
29
|
+
lines.push(
|
|
30
|
+
`- ${group.key} — ${group.count} (${this.findings.severityBreakdown(group.bySeverity)})`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
lines.push(
|
|
35
|
+
"",
|
|
36
|
+
`fila: ${totals.queue} · retornados: ${totals.returned} · grupos: ${totals.groups}`,
|
|
37
|
+
);
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderização de briefs (biblioteca) — superfície OO sobre `brief.ts`.
|
|
3
|
+
*
|
|
4
|
+
* O texto continua em `brief.ts` (funções puras); a classe existe para o
|
|
5
|
+
* pipeline injetar o renderer sem depender da implementação.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { renderBrief } from "../brief";
|
|
9
|
+
import type { Watchdog } from "../domain/watchdog";
|
|
10
|
+
import type { WatchdogDefinition, WatchdogRenderArgs } from "../types";
|
|
11
|
+
|
|
12
|
+
/** Renderiza o brief de um watchdog (hook custom ou default). */
|
|
13
|
+
export class BriefRenderer {
|
|
14
|
+
/** Render a partir de um `Watchdog` (domínio). */
|
|
15
|
+
render(watchdog: Watchdog, args: WatchdogRenderArgs): string {
|
|
16
|
+
return watchdog.renderBrief(args);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Render a partir da definição declarativa. */
|
|
20
|
+
renderDefinition(definition: WatchdogDefinition, args: WatchdogRenderArgs): string {
|
|
21
|
+
return renderBrief(definition, args);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apresentação dos findings (biblioteca) — markdown/JSON do CLI.
|
|
3
|
+
*
|
|
4
|
+
* Separado do comando para que o mesmo texto possa ser reutilizado (CLI, UI,
|
|
5
|
+
* testes) sem arrastar o parse de flags.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
FINDING_DECISIONS,
|
|
10
|
+
FINDING_EXEC_STATES,
|
|
11
|
+
type FindingDecision,
|
|
12
|
+
type FindingState,
|
|
13
|
+
type FindingsStateFile,
|
|
14
|
+
type FindingsSummary,
|
|
15
|
+
} from "../findings-state";
|
|
16
|
+
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
|
|
17
|
+
|
|
18
|
+
/** Renderiza findings em markdown compacto (uso no agente). */
|
|
19
|
+
export class FindingsPresenter {
|
|
20
|
+
/** Uma linha compacta por finding. */
|
|
21
|
+
line(finding: FindingState): string[] {
|
|
22
|
+
const emoji = SEVERITY_EMOJI[finding.severity];
|
|
23
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
24
|
+
const flags = `${this.decisionLabel(finding.decision)}/${finding.execState}`;
|
|
25
|
+
const count = finding.count > 1 ? ` ×${finding.count}` : "";
|
|
26
|
+
const lines = [
|
|
27
|
+
`- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
|
|
28
|
+
` ${finding.message}`,
|
|
29
|
+
` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
|
|
30
|
+
];
|
|
31
|
+
if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Lista de findings com cabeçalho. */
|
|
36
|
+
list(state: FindingsStateFile, findings: FindingState[]): string {
|
|
37
|
+
if (findings.length === 0) return "Nenhum finding para o filtro informado.";
|
|
38
|
+
const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
|
|
39
|
+
for (const finding of findings) lines.push(...this.line(finding));
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resumo da triagem (decisões, execução e severidade). */
|
|
44
|
+
summary(state: FindingsStateFile, summary: FindingsSummary): string {
|
|
45
|
+
const decisions = FINDING_DECISIONS.map(
|
|
46
|
+
(decision) => `${decision} ${summary.byDecision[decision]}`,
|
|
47
|
+
).join(" · ");
|
|
48
|
+
const exec = FINDING_EXEC_STATES.map(
|
|
49
|
+
(stateName) => `${stateName} ${summary.byExecState[stateName]}`,
|
|
50
|
+
).join(" · ");
|
|
51
|
+
const severities = SEVERITY_ORDER.map(
|
|
52
|
+
(severity) => `${severity} ${summary.bySeverity[severity]}`,
|
|
53
|
+
).join(" · ");
|
|
54
|
+
return [
|
|
55
|
+
`# Triagem — ${state.branch}`,
|
|
56
|
+
"",
|
|
57
|
+
`Total: ${summary.total} (presentes: ${summary.present})`,
|
|
58
|
+
`Decisões: ${decisions}`,
|
|
59
|
+
`Execução: ${exec}`,
|
|
60
|
+
`Severidade: ${severities}`,
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Mix de severidade compacto (ex.: `❌1 ⚠️2`). */
|
|
65
|
+
severityBreakdown(bySeverity: Record<Severity, number>): string {
|
|
66
|
+
return SEVERITY_ORDER.filter((severity) => bySeverity[severity] > 0)
|
|
67
|
+
.map((severity) => `${SEVERITY_EMOJI[severity]}${bySeverity[severity]}`)
|
|
68
|
+
.join(" ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private decisionLabel(decision: FindingDecision): string {
|
|
72
|
+
return decision;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifesto da run (biblioteca) — monta e grava o `manifest.json`.
|
|
3
|
+
*
|
|
4
|
+
* Concentra o resumo do que foi ativado (watchdogs, orçamento, churn, contexto,
|
|
5
|
+
* Jev e incremental), hoje espalhado em helpers do orquestrador.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Classification } from "../domain/classification";
|
|
9
|
+
import type { ClassificationResult } from "../file-classifier";
|
|
10
|
+
import type { JevCandidatesFile } from "../jev";
|
|
11
|
+
import { OUTPUT_FORMAT_PATH, relFromRoot } from "../paths";
|
|
12
|
+
import { readContextSummary } from "../pr-context";
|
|
13
|
+
import type { ProjectConfig } from "../project-config";
|
|
14
|
+
import type { MergedFinding } from "../report-builder";
|
|
15
|
+
import type { RunLayout } from "../run-layout";
|
|
16
|
+
import { systemClock } from "../services/clock";
|
|
17
|
+
import { ProjectFiles } from "../services/project-files";
|
|
18
|
+
|
|
19
|
+
/** Opções do manifesto (subset do `ReviewOptions`). */
|
|
20
|
+
export type ManifestOptions = {
|
|
21
|
+
base: string;
|
|
22
|
+
head: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Classificação enriquecida gravada pelo prepare (modo + incremental). */
|
|
26
|
+
export type ManifestClassification = ClassificationResult & {
|
|
27
|
+
headSha?: string;
|
|
28
|
+
mode: "full" | "incremental";
|
|
29
|
+
reviewedFrom?: string;
|
|
30
|
+
carryover?: MergedFinding[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Escreve o `manifest.json` de uma run. */
|
|
34
|
+
export class ManifestWriter {
|
|
35
|
+
private readonly layout: RunLayout;
|
|
36
|
+
private readonly project: ProjectConfig;
|
|
37
|
+
private readonly options: ManifestOptions;
|
|
38
|
+
|
|
39
|
+
constructor(layout: RunLayout, project: ProjectConfig, options: ManifestOptions) {
|
|
40
|
+
this.layout = layout;
|
|
41
|
+
this.project = project;
|
|
42
|
+
this.options = options;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
write(
|
|
46
|
+
classification: ManifestClassification,
|
|
47
|
+
activeIds: string[],
|
|
48
|
+
pending: string[],
|
|
49
|
+
jev: JevCandidatesFile,
|
|
50
|
+
): void {
|
|
51
|
+
const view = Classification.from(classification);
|
|
52
|
+
const maxFilesPerBrief = this.project.maxFilesPerBrief;
|
|
53
|
+
let filesOverBudget = 0;
|
|
54
|
+
|
|
55
|
+
const watchdogs = activeIds.map((id) => {
|
|
56
|
+
const selection = view.budgetFor(id, maxFilesPerBrief);
|
|
57
|
+
filesOverBudget += selection.overflow.length;
|
|
58
|
+
return {
|
|
59
|
+
id,
|
|
60
|
+
name: this.project.watchdogs.nameOf(id),
|
|
61
|
+
files: selection.selected,
|
|
62
|
+
overflow: selection.overflow.length,
|
|
63
|
+
brief: relFromRoot(this.layout.briefFile(id)),
|
|
64
|
+
findings: relFromRoot(this.layout.findingFile(id)),
|
|
65
|
+
status: pending.includes(id) ? "pending" : "done",
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const summary = readContextSummary(this.layout.contextPaths.summary);
|
|
70
|
+
const manifest = {
|
|
71
|
+
branch: this.layout.branch,
|
|
72
|
+
slug: this.layout.slug,
|
|
73
|
+
base: this.options.base,
|
|
74
|
+
head: this.options.head,
|
|
75
|
+
headSha: classification.headSha,
|
|
76
|
+
generatedAt: systemClock(),
|
|
77
|
+
outputFormat: OUTPUT_FORMAT_PATH,
|
|
78
|
+
budget: { maxFilesPerBrief, filesOverBudget },
|
|
79
|
+
churn: view.churnTotals,
|
|
80
|
+
incremental:
|
|
81
|
+
classification.mode === "incremental"
|
|
82
|
+
? {
|
|
83
|
+
from: classification.reviewedFrom,
|
|
84
|
+
changedFiles: Object.keys(classification.files).length,
|
|
85
|
+
carried: classification.carryover?.length ?? 0,
|
|
86
|
+
}
|
|
87
|
+
: undefined,
|
|
88
|
+
watchdogs,
|
|
89
|
+
context: summary
|
|
90
|
+
? { summarized: true, source: summary.source ?? null }
|
|
91
|
+
: { summarized: false, source: null },
|
|
92
|
+
jev: jev.available
|
|
93
|
+
? {
|
|
94
|
+
available: true,
|
|
95
|
+
model: jev.model,
|
|
96
|
+
threshold: jev.threshold,
|
|
97
|
+
analyzedFiles: jev.analyzedFiles,
|
|
98
|
+
candidates: jev.candidates.length,
|
|
99
|
+
skipped: jev.skipped?.length ?? 0,
|
|
100
|
+
}
|
|
101
|
+
: { available: false, reason: jev.reason },
|
|
102
|
+
deleted: classification.deleted,
|
|
103
|
+
unclassified: classification.unclassified,
|
|
104
|
+
excludedCount: classification.excludedCount,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
new ProjectFiles().write(
|
|
108
|
+
this.layout.manifestFile,
|
|
109
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderização do relatório (biblioteca) — superfície OO sobre `report-builder`.
|
|
3
|
+
*
|
|
4
|
+
* O texto continua nas funções puras; a classe fixa o resolver de nomes e evita
|
|
5
|
+
* repetir a lista de argumentos no orquestrador.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { JevCandidate } from "../jev";
|
|
9
|
+
import {
|
|
10
|
+
type ContextSummary,
|
|
11
|
+
type DomainReport,
|
|
12
|
+
type MergedFinding,
|
|
13
|
+
renderDomainReport,
|
|
14
|
+
renderReport,
|
|
15
|
+
type WatchdogNameResolver,
|
|
16
|
+
type WatchdogReport,
|
|
17
|
+
} from "../report-builder";
|
|
18
|
+
|
|
19
|
+
/** Renderiza o relatório consolidado e os relatórios por domínio. */
|
|
20
|
+
export class ReportRenderer {
|
|
21
|
+
private readonly nameOf: WatchdogNameResolver;
|
|
22
|
+
|
|
23
|
+
constructor(nameOf: WatchdogNameResolver) {
|
|
24
|
+
this.nameOf = nameOf;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Relatório consolidado (`review.md`). */
|
|
28
|
+
render(
|
|
29
|
+
branch: string,
|
|
30
|
+
reports: WatchdogReport[],
|
|
31
|
+
merged: MergedFinding[],
|
|
32
|
+
coverage: { classified?: Record<string, string[]> },
|
|
33
|
+
context?: ContextSummary | null,
|
|
34
|
+
candidates?: JevCandidate[],
|
|
35
|
+
domains: readonly DomainReport[] = [],
|
|
36
|
+
): string {
|
|
37
|
+
return renderReport(
|
|
38
|
+
branch,
|
|
39
|
+
reports,
|
|
40
|
+
merged,
|
|
41
|
+
coverage,
|
|
42
|
+
context,
|
|
43
|
+
candidates,
|
|
44
|
+
this.nameOf,
|
|
45
|
+
domains,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Relatório isolado de um watchdog (`review.<id>.md`). */
|
|
50
|
+
renderDomain(
|
|
51
|
+
branch: string,
|
|
52
|
+
report: WatchdogReport,
|
|
53
|
+
merged: MergedFinding[],
|
|
54
|
+
expectedFiles: number,
|
|
55
|
+
context?: ContextSummary | null,
|
|
56
|
+
): string {
|
|
57
|
+
return renderDomainReport(
|
|
58
|
+
branch,
|
|
59
|
+
report,
|
|
60
|
+
merged,
|
|
61
|
+
expectedFiles,
|
|
62
|
+
context,
|
|
63
|
+
this.nameOf,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apresentação da verificação de findings (biblioteca) — markdown do `verify`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FindingsStateFile } from "../findings-state";
|
|
6
|
+
import type { VerifyEntry, VerifyStatus, VerifySummary } from "../verify";
|
|
7
|
+
|
|
8
|
+
/** Rótulo curto de cada status de verificação. */
|
|
9
|
+
const VERIFY_LABEL: Record<VerifyStatus, string> = {
|
|
10
|
+
resolved: "✅ resolvido",
|
|
11
|
+
persisting: "❌ persiste",
|
|
12
|
+
uncertain: "⚠️ incerto",
|
|
13
|
+
"file-missing": "🗑️ arquivo ausente",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Metadados do Jev exibidos no cabeçalho. */
|
|
17
|
+
export type VerifyMeta = { jevChecked: number; jevReason?: string };
|
|
18
|
+
|
|
19
|
+
/** Renderiza a verificação: contagem por status + uma linha por finding. */
|
|
20
|
+
export class VerifyPresenter {
|
|
21
|
+
render(
|
|
22
|
+
state: FindingsStateFile,
|
|
23
|
+
entries: VerifyEntry[],
|
|
24
|
+
summary: VerifySummary,
|
|
25
|
+
meta: VerifyMeta,
|
|
26
|
+
): string {
|
|
27
|
+
if (entries.length === 0) return "Nenhum finding para verificar.";
|
|
28
|
+
const lines = [
|
|
29
|
+
`# Verificação — ${state.branch} (${entries.length})`,
|
|
30
|
+
"",
|
|
31
|
+
`Resolvidos: ${summary.resolved} · Persistem: ${summary.persisting} · Incertos: ${summary.uncertain} · Arquivo ausente: ${summary["file-missing"]}`,
|
|
32
|
+
];
|
|
33
|
+
if (meta.jevChecked > 0) lines.push(`Confirmados por Jev: ${meta.jevChecked}.`);
|
|
34
|
+
else if (meta.jevReason) lines.push(`Jev: pulado (${meta.jevReason}).`);
|
|
35
|
+
lines.push("");
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const location = entry.line ? `${entry.file}:${entry.line}` : entry.file;
|
|
38
|
+
const via =
|
|
39
|
+
entry.basis === "jev"
|
|
40
|
+
? ` · Jev ${(entry.confidence ?? 0).toFixed(2)}`
|
|
41
|
+
: "";
|
|
42
|
+
lines.push(
|
|
43
|
+
`- \`${entry.id}\` ${VERIFY_LABEL[entry.status]} — **${entry.severity}** \`${location}\` — ${entry.rule}${via}`,
|
|
44
|
+
` ${entry.message}`,
|
|
45
|
+
);
|
|
46
|
+
if (entry.detail) lines.push(` _${entry.detail}_`);
|
|
47
|
+
}
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apresentação do contexto de um watchdog (biblioteca) — markdown/JSON.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { WatchdogContext } from "../domain/watchdog";
|
|
6
|
+
import {
|
|
7
|
+
type Rule,
|
|
8
|
+
SEVERITY_EMOJI,
|
|
9
|
+
severityRank,
|
|
10
|
+
type WatchdogDefinition,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
/** Opções de apresentação do watchdog. */
|
|
14
|
+
export type WatchdogRenderOptions = {
|
|
15
|
+
/** Inclui as regras. */
|
|
16
|
+
withRules: boolean;
|
|
17
|
+
/** Saída JSON (default: markdown). */
|
|
18
|
+
json: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Renderiza o contexto de um watchdog. */
|
|
22
|
+
export class WatchdogPresenter {
|
|
23
|
+
render(
|
|
24
|
+
context: WatchdogContext,
|
|
25
|
+
definition: WatchdogDefinition,
|
|
26
|
+
options: WatchdogRenderOptions,
|
|
27
|
+
): string {
|
|
28
|
+
return options.json
|
|
29
|
+
? this.serialize(context, definition, options.withRules)
|
|
30
|
+
: this.markdown(context, definition, options.withRules);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Payload serializável (regras incluídas apenas com `withRules`). */
|
|
34
|
+
private serialize(
|
|
35
|
+
context: WatchdogContext,
|
|
36
|
+
definition: WatchdogDefinition,
|
|
37
|
+
withRules: boolean,
|
|
38
|
+
): string {
|
|
39
|
+
const payload = withRules
|
|
40
|
+
? { ...context, rules: definition.rules }
|
|
41
|
+
: { ...context };
|
|
42
|
+
return JSON.stringify(payload, null, 2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lista de itens em bloco `## <título>` (vazio se não houver itens). */
|
|
46
|
+
private section(title: string, items: readonly string[] | undefined): string[] {
|
|
47
|
+
if (!items || items.length === 0) return [];
|
|
48
|
+
return [`## ${title}`, "", ...items.map((item) => `- ${item}`), ""];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Regras com emoji de severidade em bloco `## <título>`. */
|
|
52
|
+
private ruleSection(title: string, rules: readonly Rule[]): string[] {
|
|
53
|
+
return [
|
|
54
|
+
`## ${title}`,
|
|
55
|
+
"",
|
|
56
|
+
...rules.map(
|
|
57
|
+
(rule) =>
|
|
58
|
+
`- ${SEVERITY_EMOJI[rule.severity]} **${rule.severity}** — ${rule.text}`,
|
|
59
|
+
),
|
|
60
|
+
"",
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render markdown legível do contexto do watchdog. */
|
|
65
|
+
private markdown(
|
|
66
|
+
context: WatchdogContext,
|
|
67
|
+
definition: WatchdogDefinition,
|
|
68
|
+
withRules: boolean,
|
|
69
|
+
): string {
|
|
70
|
+
const lines: string[] = [
|
|
71
|
+
`# ${context.name} (\`${context.id}\`)`,
|
|
72
|
+
"",
|
|
73
|
+
`**Descrição:** ${context.description}`,
|
|
74
|
+
`**Escopo:** ${context.scope}`,
|
|
75
|
+
"",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
lines.push(...this.section("Prioridade de leitura", context.reviewFocus));
|
|
79
|
+
lines.push(...this.section("Não carregar", context.exclude));
|
|
80
|
+
lines.push(
|
|
81
|
+
...this.section(
|
|
82
|
+
"Docs de contexto",
|
|
83
|
+
context.contextFiles.map((doc) => `\`${doc}\``),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (withRules) {
|
|
88
|
+
lines.push(
|
|
89
|
+
...this.ruleSection(
|
|
90
|
+
"Regras",
|
|
91
|
+
[...definition.rules].sort(
|
|
92
|
+
(a, b) => severityRank(a.severity) - severityRank(b.severity),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return lines.join("\n").trimEnd();
|
|
99
|
+
}
|
|
100
|
+
}
|