@payconductor/houndsman 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +156 -3
- package/assets/houndsman.png +0 -0
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +64 -5
- package/src/cli/index.ts +290 -0
- package/src/core/assets.ts +28 -0
- package/src/core/brief.ts +185 -0
- package/src/core/commands/context.ts +39 -0
- package/src/core/commands/findings.ts +390 -0
- package/src/core/commands/review.ts +50 -0
- package/src/core/commands/scaffold.ts +393 -0
- package/src/core/commands/setup.ts +75 -0
- package/src/core/commands/test.ts +20 -0
- package/src/core/commands/update.ts +30 -0
- package/src/core/commands/watchdog.ts +105 -0
- package/src/core/config-loader.ts +87 -0
- package/src/core/config.ts +101 -0
- package/src/core/diff-source.ts +140 -0
- package/src/core/doc-discoverer.ts +33 -0
- package/src/core/file-classifier.ts +295 -0
- package/src/core/finding-anchor.ts +147 -0
- package/src/core/findings-state.ts +538 -0
- package/src/core/jev/index.ts +12 -0
- package/src/core/jev/jev-candidates.ts +105 -0
- package/src/core/jev/jev-client.ts +160 -0
- package/src/core/jev/jev-detector.ts +205 -0
- package/src/core/jev/jev-questions.ts +92 -0
- package/src/core/jev/jev-types.ts +91 -0
- package/src/core/orchestrator.ts +596 -0
- package/src/core/paths.ts +33 -0
- package/src/core/pr-context.ts +271 -0
- package/src/core/registry-check.ts +160 -0
- package/src/core/report-builder.ts +461 -0
- package/src/core/types.ts +147 -0
- package/src/core/watchdog-registry.ts +43 -0
- package/src/index.ts +132 -0
- package/src/server/artifacts.ts +66 -0
- package/src/server/client/App.tsx +158 -0
- package/src/server/client/ArtifactsPanel.tsx +48 -0
- package/src/server/client/Filters.tsx +83 -0
- package/src/server/client/FindingCard.tsx +122 -0
- package/src/server/client/FindingsList.tsx +54 -0
- package/src/server/client/RunList.tsx +43 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +63 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +48 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +365 -0
- package/src/skill/AUDIT-PROTOCOL.md +96 -0
- package/src/skill/OUTPUT-FORMAT.md +70 -0
- package/src/skill/README.md +124 -0
- package/src/skill/SETUP.md +101 -0
- package/src/skill/example-review.md +58 -0
- package/src/skill/houndsman-update.md +251 -0
- package/src/skill/houndsman.md +316 -0
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +226 -0
- package/src/tests/findings-state.test.ts +274 -0
- package/src/tests/fixtures/fake-project.ts +64 -0
- package/src/tests/review-scripts.test.ts +1150 -0
- package/src/tests/server.test.ts +212 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderização do brief de um watchdog (biblioteca) — `defaultRenderBrief`.
|
|
3
|
+
*
|
|
4
|
+
* O procedimento (`PROCEDURE`) é compartilhado por todos os watchdogs; o que era
|
|
5
|
+
* protocolo específico vive nas regras do watchdog (`rules`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type Rule,
|
|
10
|
+
SEVERITY_EMOJI,
|
|
11
|
+
severityRank,
|
|
12
|
+
type WatchdogDefinition,
|
|
13
|
+
type WatchdogRenderArgs,
|
|
14
|
+
type WatchdogRenderCandidate,
|
|
15
|
+
type WatchdogRenderContext,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Procedimento de revisão — compartilhado por todos os watchdogs.
|
|
20
|
+
* Substitui o antigo protocolo genérico de 5 passos + o ponteiro `PROTOCOL_GLOB`.
|
|
21
|
+
*/
|
|
22
|
+
export const PROCEDURE: readonly string[] = [
|
|
23
|
+
"1. **FILTER** — descarte o que não for do seu domínio (ver *Prioridade de leitura* e *Não carregar*).",
|
|
24
|
+
"2. **READ HEAD** — leia os arquivos seletivamente (primeiras ~30 linhas). NUNCA o diff completo.",
|
|
25
|
+
"3. **GREP** — use as regras críticas/anti-padrões como heurística para localizar violações.",
|
|
26
|
+
"4. **DEEP-READ** — aprofunde apenas os arquivos com suspeita de violação.",
|
|
27
|
+
'5. **CONCLUDE** — grave o JSON no caminho de output, com `reviewedFiles` preenchido. Sem achados → `"findings": []` (arquivo obrigatório). Não escreva markdown livre.',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Bloco de contexto da PR, injetado no brief de cada watchdog. */
|
|
31
|
+
export function renderBriefContext(context: WatchdogRenderContext): string[] {
|
|
32
|
+
const lines = ["## Contexto da PR", ""];
|
|
33
|
+
if (context.objective) lines.push(`**Objetivo:** ${context.objective}`);
|
|
34
|
+
if (context.motivation) lines.push(`**Motivação:** ${context.motivation}`);
|
|
35
|
+
const changeSummary = context.changeSummary ?? [];
|
|
36
|
+
if (changeSummary.length > 0) {
|
|
37
|
+
lines.push("", "**Resumo das alterações:**", "");
|
|
38
|
+
for (const item of changeSummary) lines.push(`- ${item}`);
|
|
39
|
+
}
|
|
40
|
+
lines.push("");
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Seção de candidatos pré-analisados (Jev) — o watchdog confirma ou refuta. */
|
|
45
|
+
export function renderBriefCandidates(
|
|
46
|
+
candidates: readonly WatchdogRenderCandidate[],
|
|
47
|
+
): string[] {
|
|
48
|
+
const lines = [
|
|
49
|
+
"## Candidatos pré-analisados (Jev) — confirme ou refute",
|
|
50
|
+
"",
|
|
51
|
+
"> Indícios automáticos com confiança calibrada. **NÃO** são achados:",
|
|
52
|
+
"> confirme (virando finding, com a `rule` abaixo) ou ignore se for falso positivo.",
|
|
53
|
+
"",
|
|
54
|
+
];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
lines.push(
|
|
57
|
+
`- [ ] \`${candidate.file}\` · **${candidate.severity}** · confiança ${candidate.confidence.toFixed(2)}`,
|
|
58
|
+
` Regra: ${candidate.rule}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Linha de lista em code-span: `- \`valor\``. */
|
|
66
|
+
function codeItem(value: string): string {
|
|
67
|
+
return `- \`${value}\``;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Linha de regra com emoji de severidade. */
|
|
71
|
+
function ruleItem(rule: Rule): string {
|
|
72
|
+
return `- ${SEVERITY_EMOJI[rule.severity]} **${rule.severity}** — ${rule.text}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Seção opcional (`## <título>` + itens) — vazia se não houver itens. */
|
|
76
|
+
function optionalList(title: string, items: readonly string[] | undefined): string[] {
|
|
77
|
+
if (!items || items.length === 0) return [];
|
|
78
|
+
return [`## ${title}`, "", ...items.map((item) => `- ${item}`), ""];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lista de arquivos do domínio com status (e churn, quando disponível). */
|
|
82
|
+
function fileItems(args: WatchdogRenderArgs): string[] {
|
|
83
|
+
return args.files.map((file) => {
|
|
84
|
+
const status = args.statuses[file]?.status ?? "modified";
|
|
85
|
+
const churn = args.churn?.[file];
|
|
86
|
+
const delta = churn ? `, +${churn.additions}/−${churn.deletions}` : "";
|
|
87
|
+
return `- \`${file}\` (${status}${delta})`;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Arquivos do domínio fora do orçamento (triage de PRs grandes): o watchdog NÃO
|
|
93
|
+
* deve lê-los, mas precisa registrá-los em `skipped` — cobertura honesta.
|
|
94
|
+
*/
|
|
95
|
+
function overflowSection(args: WatchdogRenderArgs): string[] {
|
|
96
|
+
const overflow = args.overflow ?? [];
|
|
97
|
+
if (overflow.length === 0) return [];
|
|
98
|
+
return [
|
|
99
|
+
"## Fora do orçamento (NÃO revisar)",
|
|
100
|
+
"",
|
|
101
|
+
`> ${overflow.length} arquivo(s) do seu domínio ficaram fora do orçamento`,
|
|
102
|
+
"> (menor churn). Não os leia: registre-os em `skipped` com o motivo",
|
|
103
|
+
'> "budget" para que a cobertura do relatório fique honesta.',
|
|
104
|
+
"",
|
|
105
|
+
...overflow.map(codeItem),
|
|
106
|
+
"",
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Seção de deletados (breaking change) — só p/ watchdogs com `breakingChange`. */
|
|
111
|
+
function deletedSection(args: WatchdogRenderArgs, def: WatchdogDefinition): string[] {
|
|
112
|
+
if (!def.breakingChange || args.deleted.length === 0) return [];
|
|
113
|
+
return [
|
|
114
|
+
"## Deletados — checar breaking change",
|
|
115
|
+
"",
|
|
116
|
+
...args.deleted.map(codeItem),
|
|
117
|
+
"",
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render padrão do brief de um watchdog (ordem estável).
|
|
123
|
+
* `def` é a definição do próprio watchdog (evita ciclo com o registry).
|
|
124
|
+
*/
|
|
125
|
+
export function defaultRenderBrief(
|
|
126
|
+
args: WatchdogRenderArgs,
|
|
127
|
+
def: WatchdogDefinition,
|
|
128
|
+
): string[] {
|
|
129
|
+
const lines: string[] = [
|
|
130
|
+
`# Brief — ${def.name} (\`${def.id}\`)`,
|
|
131
|
+
"",
|
|
132
|
+
`**Branch:** ${args.branch}`,
|
|
133
|
+
`**Output obrigatório:** \`${args.outputPath}\``,
|
|
134
|
+
`**Schema do output:** \`${args.outputFormatPath}\``,
|
|
135
|
+
"",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
if (def.description) lines.push("## Objetivo", "", def.description, "");
|
|
139
|
+
lines.push("## Escopo", "", def.scope, "");
|
|
140
|
+
|
|
141
|
+
if (args.context) lines.push(...renderBriefContext(args.context));
|
|
142
|
+
|
|
143
|
+
lines.push(...optionalList("Prioridade de leitura", def.reviewFocus));
|
|
144
|
+
lines.push(
|
|
145
|
+
"## Regras (por severidade)",
|
|
146
|
+
"",
|
|
147
|
+
...[...def.rules]
|
|
148
|
+
.sort((a, b) => severityRank(a.severity) - severityRank(b.severity))
|
|
149
|
+
.map(ruleItem),
|
|
150
|
+
"",
|
|
151
|
+
);
|
|
152
|
+
lines.push(
|
|
153
|
+
"## Docs de contexto (carregar sob demanda)",
|
|
154
|
+
"",
|
|
155
|
+
...args.docs.map(codeItem),
|
|
156
|
+
"",
|
|
157
|
+
);
|
|
158
|
+
lines.push(
|
|
159
|
+
`## Arquivos do seu domínio (${args.files.length})`,
|
|
160
|
+
"",
|
|
161
|
+
...fileItems(args),
|
|
162
|
+
"",
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
lines.push(...overflowSection(args));
|
|
166
|
+
|
|
167
|
+
if (args.candidates && args.candidates.length > 0) {
|
|
168
|
+
lines.push(...renderBriefCandidates(args.candidates));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lines.push(...deletedSection(args, def));
|
|
172
|
+
lines.push(...optionalList("Não carregar", def.exclude));
|
|
173
|
+
lines.push("## Procedimento", "", ...PROCEDURE);
|
|
174
|
+
|
|
175
|
+
return lines;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Renderiza o brief via hook custom (`def.renderBrief`) ou o padrão.
|
|
180
|
+
* Passa a própria definição para que o hook possa reutilizar o render padrão.
|
|
181
|
+
*/
|
|
182
|
+
export function renderBrief(def: WatchdogDefinition, args: WatchdogRenderArgs): string {
|
|
183
|
+
const render = def.renderBrief ?? defaultRenderBrief;
|
|
184
|
+
return render(args, def).join("\n");
|
|
185
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcomando `context` — coleta o contexto da PR via `gh` (Passo 0).
|
|
3
|
+
*
|
|
4
|
+
* Biblioteca: grava os artefatos e devolve os dados; o CLI faz o log/exit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { type HoundsmanConfig, resolveArtifactDir } from "../config";
|
|
9
|
+
import {
|
|
10
|
+
type ContextPaths,
|
|
11
|
+
collectPrContext,
|
|
12
|
+
contextPaths,
|
|
13
|
+
currentBranch,
|
|
14
|
+
type PrContextRaw,
|
|
15
|
+
renderContextBrief,
|
|
16
|
+
} from "../pr-context";
|
|
17
|
+
|
|
18
|
+
export type ContextRunResult = {
|
|
19
|
+
paths: ContextPaths;
|
|
20
|
+
context: PrContextRaw;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Coleta a PR atual e grava `context.json` + `context-brief.md`. */
|
|
24
|
+
export function runContextCommand(
|
|
25
|
+
config: HoundsmanConfig,
|
|
26
|
+
opts: { branch?: string; dir?: string } = {},
|
|
27
|
+
): ContextRunResult {
|
|
28
|
+
const branch = opts.branch ?? currentBranch();
|
|
29
|
+
const dir = opts.dir ?? resolveArtifactDir(config);
|
|
30
|
+
const paths = contextPaths(dir, branch);
|
|
31
|
+
|
|
32
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const context = collectPrContext(branch);
|
|
35
|
+
writeFileSync(paths.context, `${JSON.stringify(context, null, 2)}\n`);
|
|
36
|
+
writeFileSync(paths.brief, `${renderContextBrief(context, branch, paths)}\n`);
|
|
37
|
+
|
|
38
|
+
return { paths, context };
|
|
39
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcomando `findings` — triagem dos findings da revisão (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Lê/grava o `state.json` da run (`findings-state.ts`). Devolve o texto pronto
|
|
5
|
+
* (markdown ou JSON) + exit code; o CLI decide o log/exit. O agente do usuário
|
|
6
|
+
* usa `list`/`set`/`done` para interagir sem abrir o JSON à mão.
|
|
7
|
+
*
|
|
8
|
+
* Se o `state.json` não existir, é re-construído a partir de
|
|
9
|
+
* `findings/*.json` + `classification.json` (não exige `--finalize`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type HoundsmanConfig, resolveArtifactDir } from "../config";
|
|
13
|
+
import {
|
|
14
|
+
applyFindingUpdate,
|
|
15
|
+
buildStateFromRunDir,
|
|
16
|
+
FINDING_DECISIONS,
|
|
17
|
+
FINDING_EXEC_STATES,
|
|
18
|
+
type FindingDecision,
|
|
19
|
+
type FindingExecState,
|
|
20
|
+
type FindingState,
|
|
21
|
+
type FindingsFilter,
|
|
22
|
+
type FindingsStateFile,
|
|
23
|
+
FindingsStateStore,
|
|
24
|
+
type FindingsSummary,
|
|
25
|
+
type FindingUpdate,
|
|
26
|
+
fileReader,
|
|
27
|
+
filterFindings,
|
|
28
|
+
runDir,
|
|
29
|
+
summarize,
|
|
30
|
+
syncState,
|
|
31
|
+
} from "../findings-state";
|
|
32
|
+
import { relFromRoot } from "../paths";
|
|
33
|
+
import { currentBranch } from "../pr-context";
|
|
34
|
+
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
|
|
35
|
+
|
|
36
|
+
export type FindingsCommandResult = {
|
|
37
|
+
output: string;
|
|
38
|
+
exitCode: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const VALUE_FLAGS = new Set([
|
|
42
|
+
"--decision",
|
|
43
|
+
"--exec",
|
|
44
|
+
"--watchdog",
|
|
45
|
+
"--severity",
|
|
46
|
+
"--file",
|
|
47
|
+
"--branch",
|
|
48
|
+
"--dir",
|
|
49
|
+
"--limit",
|
|
50
|
+
"--comment",
|
|
51
|
+
"--by",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
type ParsedArgs = {
|
|
55
|
+
sub?: string;
|
|
56
|
+
positionals: string[];
|
|
57
|
+
values: Record<string, string>;
|
|
58
|
+
bools: Set<string>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Parse simples: primeiro não-flag = sub; demais = positionais. */
|
|
62
|
+
function parseArgs(args: string[]): ParsedArgs {
|
|
63
|
+
const parsed: ParsedArgs = { positionals: [], values: {}, bools: new Set() };
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const arg = args[i];
|
|
66
|
+
if (arg.startsWith("--")) {
|
|
67
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
68
|
+
const value = args[i + 1];
|
|
69
|
+
if (value !== undefined) parsed.values[arg] = value;
|
|
70
|
+
i++;
|
|
71
|
+
} else {
|
|
72
|
+
parsed.bools.add(arg);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!parsed.sub) parsed.sub = arg;
|
|
77
|
+
else parsed.positionals.push(arg);
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Diretório da run + branch resolvida. */
|
|
83
|
+
function resolveDir(
|
|
84
|
+
config: HoundsmanConfig,
|
|
85
|
+
parsed: ParsedArgs,
|
|
86
|
+
): { dir: string; branch: string } {
|
|
87
|
+
return {
|
|
88
|
+
dir: parsed.values["--dir"] ?? resolveArtifactDir(config),
|
|
89
|
+
branch: parsed.values["--branch"] ?? currentBranch(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Lê o estado da run; reconstrói dos findings se ausente. Lança se impossível. */
|
|
94
|
+
function loadState(config: HoundsmanConfig, parsed: ParsedArgs): FindingsStateFile {
|
|
95
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
96
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
97
|
+
const existing = store.read();
|
|
98
|
+
if (existing) return existing;
|
|
99
|
+
|
|
100
|
+
const built = buildStateFromRunDir(
|
|
101
|
+
runDir(dir, branch),
|
|
102
|
+
branch,
|
|
103
|
+
fileReader(process.cwd()),
|
|
104
|
+
);
|
|
105
|
+
if (!built) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Nenhum estado de triagem para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
store.write(built);
|
|
111
|
+
return built;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Render
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function decisionLabel(decision: FindingDecision): string {
|
|
119
|
+
return decision;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Uma linha compacta por finding (uso no agente). */
|
|
123
|
+
function renderFindingLine(finding: FindingState): string[] {
|
|
124
|
+
const emoji = SEVERITY_EMOJI[finding.severity];
|
|
125
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
126
|
+
const flags = `${decisionLabel(finding.decision)}/${finding.execState}`;
|
|
127
|
+
const count = finding.count > 1 ? ` ×${finding.count}` : "";
|
|
128
|
+
const lines = [
|
|
129
|
+
`- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
|
|
130
|
+
` ${finding.message}`,
|
|
131
|
+
` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
|
|
132
|
+
];
|
|
133
|
+
if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderList(state: FindingsStateFile, findings: FindingState[]): string {
|
|
138
|
+
if (findings.length === 0) return "Nenhum finding para o filtro informado.";
|
|
139
|
+
const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
|
|
140
|
+
for (const finding of findings) lines.push(...renderFindingLine(finding));
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderSummary(state: FindingsStateFile, summary: FindingsSummary): string {
|
|
145
|
+
const decisions = FINDING_DECISIONS.map((d) => `${d} ${summary.byDecision[d]}`).join(
|
|
146
|
+
" · ",
|
|
147
|
+
);
|
|
148
|
+
const exec = FINDING_EXEC_STATES.map((e) => `${e} ${summary.byExecState[e]}`).join(
|
|
149
|
+
" · ",
|
|
150
|
+
);
|
|
151
|
+
const severities = SEVERITY_ORDER.map((s) => `${s} ${summary.bySeverity[s]}`).join(
|
|
152
|
+
" · ",
|
|
153
|
+
);
|
|
154
|
+
return [
|
|
155
|
+
`# Triagem — ${state.branch}`,
|
|
156
|
+
"",
|
|
157
|
+
`Total: ${summary.total} (presentes: ${summary.present})`,
|
|
158
|
+
`Decisões: ${decisions}`,
|
|
159
|
+
`Execução: ${exec}`,
|
|
160
|
+
`Severidade: ${severities}`,
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Execução
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function parseDecision(raw: string | undefined): FindingDecision | undefined {
|
|
169
|
+
if (!raw) return undefined;
|
|
170
|
+
if (!FINDING_DECISIONS.includes(raw as FindingDecision)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`--decision inválido: '${raw}'. Use: ${FINDING_DECISIONS.join(", ")}.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return raw as FindingDecision;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseExec(raw: string | undefined): FindingExecState | undefined {
|
|
179
|
+
if (!raw) return undefined;
|
|
180
|
+
if (!FINDING_EXEC_STATES.includes(raw as FindingExecState)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`--exec inválido: '${raw}'. Use: ${FINDING_EXEC_STATES.join(", ")}.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return raw as FindingExecState;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function filterFrom(parsed: ParsedArgs): FindingsFilter {
|
|
189
|
+
const severity = parsed.values["--severity"];
|
|
190
|
+
return {
|
|
191
|
+
decision: parseDecision(parsed.values["--decision"]),
|
|
192
|
+
execState: parseExec(parsed.values["--exec"]),
|
|
193
|
+
watchdog: parsed.values["--watchdog"],
|
|
194
|
+
severity: severity ? (severity as Severity) : undefined,
|
|
195
|
+
file: parsed.values["--file"],
|
|
196
|
+
includeAbsent: parsed.bools.has("--all"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Aplica uma atualização a um finding e persiste. */
|
|
201
|
+
function updateOne(
|
|
202
|
+
config: HoundsmanConfig,
|
|
203
|
+
parsed: ParsedArgs,
|
|
204
|
+
id: string,
|
|
205
|
+
update: FindingUpdate,
|
|
206
|
+
): FindingState {
|
|
207
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
208
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
209
|
+
let updated: FindingState | undefined;
|
|
210
|
+
store.update((state) => {
|
|
211
|
+
const finding = state.findings[id];
|
|
212
|
+
if (!finding) return;
|
|
213
|
+
updated = applyFindingUpdate(finding, update);
|
|
214
|
+
});
|
|
215
|
+
if (!updated) throw new Error(`Finding '${id}' não encontrado.`);
|
|
216
|
+
return updated;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Aplica a mesma atualização a vários ids (done/reset). */
|
|
220
|
+
function updateMany(
|
|
221
|
+
config: HoundsmanConfig,
|
|
222
|
+
parsed: ParsedArgs,
|
|
223
|
+
ids: string[],
|
|
224
|
+
update: FindingUpdate,
|
|
225
|
+
): FindingState[] {
|
|
226
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
227
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
228
|
+
const updated: FindingState[] = [];
|
|
229
|
+
store.update((state) => {
|
|
230
|
+
for (const id of ids) {
|
|
231
|
+
const finding = state.findings[id];
|
|
232
|
+
if (!finding) continue;
|
|
233
|
+
updated.push(applyFindingUpdate(finding, update));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const missing = ids.filter((id) => !updated.some((f) => f.id === id));
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
throw new Error(`Findings não encontrados: ${missing.join(", ")}.`);
|
|
239
|
+
}
|
|
240
|
+
return updated;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Executa um subcomando de `findings` e devolve o texto + exit code. */
|
|
244
|
+
export function runFindingsCommand(
|
|
245
|
+
config: HoundsmanConfig,
|
|
246
|
+
args: string[],
|
|
247
|
+
): FindingsCommandResult {
|
|
248
|
+
const parsed = parseArgs(args);
|
|
249
|
+
try {
|
|
250
|
+
return execute(config, parsed);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return {
|
|
253
|
+
output: error instanceof Error ? error.message : String(error),
|
|
254
|
+
exitCode: 1,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandResult {
|
|
260
|
+
switch (parsed.sub ?? "list") {
|
|
261
|
+
case "list": {
|
|
262
|
+
const state = loadState(config, parsed);
|
|
263
|
+
const filtered = filterFindings(state, filterFrom(parsed));
|
|
264
|
+
const limit = Number(parsed.values["--limit"]);
|
|
265
|
+
const findings =
|
|
266
|
+
Number.isFinite(limit) && limit > 0 ? filtered.slice(0, limit) : filtered;
|
|
267
|
+
return {
|
|
268
|
+
output: parsed.bools.has("--json")
|
|
269
|
+
? JSON.stringify(findings, null, 2)
|
|
270
|
+
: renderList(state, findings),
|
|
271
|
+
exitCode: 0,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case "show": {
|
|
275
|
+
const state = loadState(config, parsed);
|
|
276
|
+
const id = parsed.positionals[0];
|
|
277
|
+
if (!id) throw new Error("Informe o id: houndsman findings show <id>");
|
|
278
|
+
const finding = state.findings[id];
|
|
279
|
+
if (!finding) throw new Error(`Finding '${id}' não encontrado.`);
|
|
280
|
+
return {
|
|
281
|
+
output: parsed.bools.has("--json")
|
|
282
|
+
? JSON.stringify(finding, null, 2)
|
|
283
|
+
: renderList(state, [finding]),
|
|
284
|
+
exitCode: 0,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
case "set": {
|
|
288
|
+
const id = parsed.positionals[0];
|
|
289
|
+
if (!id) throw new Error("Informe o id: houndsman findings set <id> [flags]");
|
|
290
|
+
const update = updateFrom(parsed);
|
|
291
|
+
if (!hasUpdate(update)) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
"Nada para atualizar (use --decision, --exec ou --comment).",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const finding = updateOne(config, parsed, id, update);
|
|
297
|
+
return {
|
|
298
|
+
output: parsed.bools.has("--json")
|
|
299
|
+
? JSON.stringify(finding, null, 2)
|
|
300
|
+
: `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
|
|
301
|
+
exitCode: 0,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
case "done": {
|
|
305
|
+
const ids = requireIds(parsed, "done");
|
|
306
|
+
const updated = updateMany(config, parsed, ids, { execState: "done" });
|
|
307
|
+
return {
|
|
308
|
+
output: `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
|
|
309
|
+
exitCode: 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
case "reset": {
|
|
313
|
+
const ids = requireIds(parsed, "reset");
|
|
314
|
+
const updated = updateMany(config, parsed, ids, {
|
|
315
|
+
decision: "pending",
|
|
316
|
+
execState: "pending",
|
|
317
|
+
comment: "",
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
output: `Triagem reiniciada: ${updated.map((f) => f.id).join(", ")}.`,
|
|
321
|
+
exitCode: 0,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
case "sync": {
|
|
325
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
326
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
327
|
+
const state = loadState(config, parsed);
|
|
328
|
+
const result = syncState(state, fileReader(process.cwd()));
|
|
329
|
+
store.write(state);
|
|
330
|
+
const output = parsed.bools.has("--json")
|
|
331
|
+
? JSON.stringify(result, null, 2)
|
|
332
|
+
: [
|
|
333
|
+
`Sync — ${branch}: ${result.ok} ok, ${result.moved.length} movido(s), ${result.stale.length} stale.`,
|
|
334
|
+
result.moved.length > 0
|
|
335
|
+
? `Movidos: ${result.moved.join(", ")}`
|
|
336
|
+
: "",
|
|
337
|
+
result.stale.length > 0
|
|
338
|
+
? `Stale: ${result.stale.join(", ")}`
|
|
339
|
+
: "",
|
|
340
|
+
]
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join("\n");
|
|
343
|
+
return { output, exitCode: 0 };
|
|
344
|
+
}
|
|
345
|
+
case "summary": {
|
|
346
|
+
const state = loadState(config, parsed);
|
|
347
|
+
const summary = summarize(state);
|
|
348
|
+
return {
|
|
349
|
+
output: parsed.bools.has("--json")
|
|
350
|
+
? JSON.stringify(summary, null, 2)
|
|
351
|
+
: renderSummary(state, summary),
|
|
352
|
+
exitCode: 0,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
case "path": {
|
|
356
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
357
|
+
const path = FindingsStateStore.at(dir, branch).filePath;
|
|
358
|
+
return { output: relFromRoot(path), exitCode: 0 };
|
|
359
|
+
}
|
|
360
|
+
default:
|
|
361
|
+
return {
|
|
362
|
+
output: `Subcomando '${parsed.sub}' desconhecido. Use: list, show, set, done, reset, sync, summary, path.`,
|
|
363
|
+
exitCode: 1,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function updateFrom(parsed: ParsedArgs): FindingUpdate {
|
|
369
|
+
return {
|
|
370
|
+
decision: parseDecision(parsed.values["--decision"]),
|
|
371
|
+
execState: parseExec(parsed.values["--exec"]),
|
|
372
|
+
comment: parsed.values["--comment"],
|
|
373
|
+
by: parsed.values["--by"],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function hasUpdate(update: FindingUpdate): boolean {
|
|
378
|
+
return (
|
|
379
|
+
update.decision !== undefined ||
|
|
380
|
+
update.execState !== undefined ||
|
|
381
|
+
update.comment !== undefined
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function requireIds(parsed: ParsedArgs, sub: string): string[] {
|
|
386
|
+
if (parsed.positionals.length === 0) {
|
|
387
|
+
throw new Error(`Informe ao menos um id: houndsman findings ${sub} <id...>`);
|
|
388
|
+
}
|
|
389
|
+
return parsed.positionals;
|
|
390
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcomando default — parse de flags + prepare/finalize.
|
|
3
|
+
*
|
|
4
|
+
* Biblioteca: NÃO usa `process.argv`/`process.exit` (o CLI decide o exit code).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defaultOptions, type ReviewOptions } from "../orchestrator";
|
|
8
|
+
import { ARTIFACT_DIR } from "../paths";
|
|
9
|
+
|
|
10
|
+
const VALUE_FLAGS = new Set(["--base", "--head", "--branch", "--dir"]);
|
|
11
|
+
const BOOLEAN_FLAGS = new Set([
|
|
12
|
+
"--finalize",
|
|
13
|
+
"--report-only",
|
|
14
|
+
"--fail-on-blocking",
|
|
15
|
+
"--clean",
|
|
16
|
+
"--no-jev",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function applyValue(opts: ReviewOptions, flag: string, value: string | undefined): void {
|
|
20
|
+
if (!value) return;
|
|
21
|
+
if (flag === "--base") opts.base = value;
|
|
22
|
+
else if (flag === "--head") opts.head = value;
|
|
23
|
+
else if (flag === "--branch") opts.branch = value;
|
|
24
|
+
else if (flag === "--dir") opts.dir = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applyBoolean(opts: ReviewOptions, flag: string): void {
|
|
28
|
+
if (flag === "--finalize" || flag === "--report-only") opts.finalize = true;
|
|
29
|
+
else if (flag === "--fail-on-blocking") opts.failOnBlocking = true;
|
|
30
|
+
else if (flag === "--clean") opts.clean = true;
|
|
31
|
+
else if (flag === "--no-jev") opts.noJev = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Converte argv (sem o subcomando) nas opções de prepare/finalize. */
|
|
35
|
+
export function parseReviewArgs(
|
|
36
|
+
args: string[],
|
|
37
|
+
artifactDir: string = ARTIFACT_DIR,
|
|
38
|
+
): ReviewOptions {
|
|
39
|
+
const opts = defaultOptions(artifactDir);
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const arg = args[i];
|
|
42
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
43
|
+
applyValue(opts, arg, args[i + 1]);
|
|
44
|
+
i++;
|
|
45
|
+
} else if (BOOLEAN_FLAGS.has(arg)) {
|
|
46
|
+
applyBoolean(opts, arg);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return opts;
|
|
50
|
+
}
|