@payconductor/houndsman 0.1.3 → 0.2.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/index.ts +31 -21
- package/src/core/commands/findings.ts +137 -11
- package/src/core/findings-state.ts +19 -0
- package/src/core/github-review.ts +452 -0
- package/src/core/registry-check.ts +53 -0
- 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 +352 -88
- package/src/skill/AUDIT-PROTOCOL.md +5 -0
- package/src/skill/README.md +3 -1
- package/src/skill/SETUP.md +0 -1
- package/src/skill/hunt.md +2 -1
- package/src/skill/train.md +29 -8
- package/src/tests/__snapshots__/baseline.test.ts.snap +0 -0
- package/src/tests/baseline.test.ts +493 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/review-scripts.test.ts +36 -0
- package/src/tests/server.test.ts +117 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Envio dos findings como comentários na Pull Request (biblioteca), via GitHub
|
|
3
|
+
* CLI (`gh`) — a capability de **"enviar revisão"**.
|
|
4
|
+
*
|
|
5
|
+
* Um comentário por finding via `POST repos/{repo}/pulls/{n}/comments`:
|
|
6
|
+
* **na linha** do diff quando o finding tem linha; **no arquivo**
|
|
7
|
+
* (`subject_type: file`) quando não tem. Se nenhum dos dois colar (ex.: arquivo
|
|
8
|
+
* fora do diff), cai em **um** comentário geral da PR. O `id`/URL de cada
|
|
9
|
+
* comentário é gravado no finding (`FindingState.github`): evita reenvio e
|
|
10
|
+
* mantém o vínculo local↔remoto.
|
|
11
|
+
*
|
|
12
|
+
* O `gh` é OPCIONAL e falha-aberto: sem binário/login/PR a prévia volta
|
|
13
|
+
* `available:false` e o envio é no-op (como o resto do houndsman).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
compareFindings,
|
|
18
|
+
type FindingDecision,
|
|
19
|
+
type FindingExecState,
|
|
20
|
+
type FindingState,
|
|
21
|
+
type FindingsStateFile,
|
|
22
|
+
} from "./findings-state";
|
|
23
|
+
import { SEVERITY_EMOJI, type Severity } from "./types";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Runner do `gh` (injetável nos testes)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type GhResult = { ok: boolean; code: number; stdout: string; stderr: string };
|
|
30
|
+
|
|
31
|
+
/** Executa o `gh` e devolve o resultado cru (sem lançar). */
|
|
32
|
+
export type GhRunner = (args: string[]) => GhResult;
|
|
33
|
+
|
|
34
|
+
/** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
|
|
35
|
+
export const defaultGhRunner: GhRunner = (args) => {
|
|
36
|
+
try {
|
|
37
|
+
const result = Bun.spawnSync(["gh", ...args], {
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
const code = result.exitCode ?? 1;
|
|
42
|
+
return {
|
|
43
|
+
ok: code === 0,
|
|
44
|
+
code,
|
|
45
|
+
stdout: new TextDecoder().decode(result.stdout).trim(),
|
|
46
|
+
stderr: new TextDecoder().decode(result.stderr).trim(),
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
code: 127,
|
|
52
|
+
stdout: "",
|
|
53
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Alvo (PR + repositório)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export type PrTarget = {
|
|
63
|
+
/** Número da PR. */
|
|
64
|
+
number: number;
|
|
65
|
+
/** `owner/name`. */
|
|
66
|
+
repo: string;
|
|
67
|
+
/** SHA do head revisado (base do `commit_id` dos comentários). */
|
|
68
|
+
headSha?: string;
|
|
69
|
+
url?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type TargetResult = { ok: true; target: PrTarget } | { ok: false; reason: string };
|
|
73
|
+
|
|
74
|
+
/** Extrai `owner/name` de uma URL de PR do GitHub. `null` se não casar. */
|
|
75
|
+
function repoFromUrl(url: string | undefined): string | null {
|
|
76
|
+
if (!url) return null;
|
|
77
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/);
|
|
78
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Mensagem curta para um erro do `gh`. */
|
|
82
|
+
function shortError(result: GhResult): string {
|
|
83
|
+
if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
|
|
84
|
+
return "gh não instalado.";
|
|
85
|
+
}
|
|
86
|
+
const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
|
|
87
|
+
if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
|
|
88
|
+
return last || `gh saiu com código ${result.code}.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a PR da branch + o repositório via `gh pr view`. Fail-open: devolve
|
|
93
|
+
* `{ ok:false, reason }` quando não há `gh`, login ou PR.
|
|
94
|
+
*/
|
|
95
|
+
export function resolvePrTarget(runner: GhRunner, branch: string): TargetResult {
|
|
96
|
+
const view = runner(["pr", "view", branch, "--json", "number,url,headRefOid"]);
|
|
97
|
+
if (!view.ok) return { ok: false, reason: shortError(view) };
|
|
98
|
+
|
|
99
|
+
let payload: { number?: number; url?: string; headRefOid?: string };
|
|
100
|
+
try {
|
|
101
|
+
payload = JSON.parse(view.stdout) as typeof payload;
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, reason: "Resposta inválida do gh." };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const repo = repoFromUrl(payload.url);
|
|
107
|
+
if (!payload.number || !repo) {
|
|
108
|
+
return { ok: false, reason: "Não foi possível identificar a PR/repositório." };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
target: {
|
|
113
|
+
number: payload.number,
|
|
114
|
+
repo,
|
|
115
|
+
headSha: payload.headRefOid,
|
|
116
|
+
url: payload.url,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Seleção
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export type SubmitSelection = {
|
|
126
|
+
/** Severidades incluídas (vazio = todas). */
|
|
127
|
+
severities?: Severity[];
|
|
128
|
+
/** Estados de execução incluídos (vazio = todos). */
|
|
129
|
+
execStates?: FindingExecState[];
|
|
130
|
+
/** Decisões incluídas (default: `pending` + `execute` — nunca `ignore`). */
|
|
131
|
+
decisions?: FindingDecision[];
|
|
132
|
+
/** Reenvia findings já vinculados a um comentário. */
|
|
133
|
+
includeSent?: boolean;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Decisões elegíveis por padrão — o `ignore` (descartado na triagem) nunca sai. */
|
|
137
|
+
export const DEFAULT_SUBMIT_DECISIONS: readonly FindingDecision[] = [
|
|
138
|
+
"pending",
|
|
139
|
+
"execute",
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
export type SubmitCandidate = {
|
|
143
|
+
id: string;
|
|
144
|
+
file: string;
|
|
145
|
+
line: number | null;
|
|
146
|
+
severity: Severity;
|
|
147
|
+
execState: FindingExecState;
|
|
148
|
+
rule: string;
|
|
149
|
+
message: string;
|
|
150
|
+
/** Onde o comentário será ancorado: na linha do diff ou no arquivo. */
|
|
151
|
+
kind: "line" | "file";
|
|
152
|
+
/** Já vinculado a um comentário remoto. */
|
|
153
|
+
sent: boolean;
|
|
154
|
+
/** URL do comentário já enviado (quando houver). */
|
|
155
|
+
sentUrl?: string;
|
|
156
|
+
/** Será pulado (já enviado e `includeSent` desligado). */
|
|
157
|
+
skip: boolean;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Seleciona os findings elegíveis ao envio: presentes, decisão elegível (sem
|
|
162
|
+
* `ignore`), casando os filtros de severidade/execução. Marca `sent`/`skip`.
|
|
163
|
+
*/
|
|
164
|
+
export function selectSubmitCandidates(
|
|
165
|
+
state: FindingsStateFile,
|
|
166
|
+
selection: SubmitSelection = {},
|
|
167
|
+
): SubmitCandidate[] {
|
|
168
|
+
const severities = new Set<Severity>(selection.severities ?? []);
|
|
169
|
+
const execStates = new Set<FindingExecState>(selection.execStates ?? []);
|
|
170
|
+
const decisions = new Set<FindingDecision>(
|
|
171
|
+
selection.decisions ?? DEFAULT_SUBMIT_DECISIONS,
|
|
172
|
+
);
|
|
173
|
+
const includeSent = selection.includeSent ?? false;
|
|
174
|
+
|
|
175
|
+
return Object.values(state.findings)
|
|
176
|
+
.filter((finding) => finding.present)
|
|
177
|
+
.filter((finding) => decisions.has(finding.decision))
|
|
178
|
+
.filter((finding) => severities.size === 0 || severities.has(finding.severity))
|
|
179
|
+
.filter((finding) => execStates.size === 0 || execStates.has(finding.execState))
|
|
180
|
+
.sort(compareFindings)
|
|
181
|
+
.map((finding) => {
|
|
182
|
+
const sent = Boolean(finding.github?.commentId);
|
|
183
|
+
return {
|
|
184
|
+
id: finding.id,
|
|
185
|
+
file: finding.file,
|
|
186
|
+
line: finding.line,
|
|
187
|
+
severity: finding.severity,
|
|
188
|
+
execState: finding.execState,
|
|
189
|
+
rule: finding.rule,
|
|
190
|
+
message: finding.message,
|
|
191
|
+
kind: finding.line !== null ? "line" : "file",
|
|
192
|
+
sent,
|
|
193
|
+
sentUrl: finding.github?.url,
|
|
194
|
+
skip: sent && !includeSent,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type SubmitPreview = {
|
|
200
|
+
available: boolean;
|
|
201
|
+
reason?: string;
|
|
202
|
+
target?: PrTarget;
|
|
203
|
+
candidates: SubmitCandidate[];
|
|
204
|
+
totals: {
|
|
205
|
+
/** Total de candidatos (após os filtros). */
|
|
206
|
+
candidates: number;
|
|
207
|
+
/** Quantos serão efetivamente enviados. */
|
|
208
|
+
toSend: number;
|
|
209
|
+
/** Comentários ancorados na linha do diff. */
|
|
210
|
+
line: number;
|
|
211
|
+
/** Comentários ancorados no arquivo (sem linha específica). */
|
|
212
|
+
file: number;
|
|
213
|
+
/** Já enviados (no total de candidatos). */
|
|
214
|
+
sent: number;
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Monta a prévia do envio a partir do estado + alvo resolvido. */
|
|
219
|
+
export function buildSubmitPreview(
|
|
220
|
+
state: FindingsStateFile,
|
|
221
|
+
target: TargetResult,
|
|
222
|
+
selection: SubmitSelection = {},
|
|
223
|
+
): SubmitPreview {
|
|
224
|
+
const candidates = selectSubmitCandidates(state, selection);
|
|
225
|
+
const toSend = candidates.filter((candidate) => !candidate.skip);
|
|
226
|
+
return {
|
|
227
|
+
available: target.ok,
|
|
228
|
+
reason: target.ok ? undefined : target.reason,
|
|
229
|
+
target: target.ok ? target.target : undefined,
|
|
230
|
+
candidates,
|
|
231
|
+
totals: {
|
|
232
|
+
candidates: candidates.length,
|
|
233
|
+
toSend: toSend.length,
|
|
234
|
+
line: toSend.filter((candidate) => candidate.kind === "line").length,
|
|
235
|
+
file: toSend.filter((candidate) => candidate.kind === "file").length,
|
|
236
|
+
sent: candidates.filter((candidate) => candidate.sent).length,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Corpo dos comentários
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/** Corpo (markdown) do comentário inline de um finding. */
|
|
246
|
+
export function buildCommentBody(finding: FindingState): string {
|
|
247
|
+
const lines = [
|
|
248
|
+
`${SEVERITY_EMOJI[finding.severity]} **${finding.rule}** · _${finding.severity}_`,
|
|
249
|
+
"",
|
|
250
|
+
finding.message,
|
|
251
|
+
"",
|
|
252
|
+
`**Ação:** ${finding.action}`,
|
|
253
|
+
];
|
|
254
|
+
if (finding.evidence) lines.push("", "```", finding.evidence, "```");
|
|
255
|
+
lines.push("", "_— houndsman_");
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Corpo (markdown) do comentário geral com os findings sem posição no diff. */
|
|
260
|
+
export function renderGeneralBody(findings: readonly FindingState[]): string {
|
|
261
|
+
const lines = [
|
|
262
|
+
"## Revisão do houndsman",
|
|
263
|
+
"",
|
|
264
|
+
"Achados sem posição no diff (comentados aqui):",
|
|
265
|
+
"",
|
|
266
|
+
];
|
|
267
|
+
for (const finding of findings) {
|
|
268
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
269
|
+
lines.push(
|
|
270
|
+
`- ${SEVERITY_EMOJI[finding.severity]} **${finding.severity}** \`${location}\` — ${finding.rule}`,
|
|
271
|
+
` ${finding.message}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return lines.join("\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Envio
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
export type PostedComment = {
|
|
282
|
+
id: string;
|
|
283
|
+
kind: "line" | "file" | "comment";
|
|
284
|
+
commentId: number;
|
|
285
|
+
url?: string;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export type SubmitOutcome = {
|
|
289
|
+
posted: PostedComment[];
|
|
290
|
+
failed: { id: string; reason: string }[];
|
|
291
|
+
skipped: { id: string; reason: string }[];
|
|
292
|
+
/** Comentário geral (quando houve findings sem posição/falha de inline). */
|
|
293
|
+
general?: { commentId: number; url?: string; ids: string[] };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
type PostResult =
|
|
297
|
+
| { ok: true; commentId: number; url?: string }
|
|
298
|
+
| { ok: false; reason: string };
|
|
299
|
+
|
|
300
|
+
function parseComment(result: GhResult): PostResult {
|
|
301
|
+
if (!result.ok) return { ok: false, reason: shortError(result) };
|
|
302
|
+
try {
|
|
303
|
+
const payload = JSON.parse(result.stdout) as { id?: number; html_url?: string };
|
|
304
|
+
if (!payload.id) return { ok: false, reason: "resposta sem id." };
|
|
305
|
+
return { ok: true, commentId: payload.id, url: payload.html_url };
|
|
306
|
+
} catch {
|
|
307
|
+
return { ok: false, reason: "resposta inválida do gh." };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function commentArgs(
|
|
312
|
+
target: PrTarget,
|
|
313
|
+
finding: FindingState,
|
|
314
|
+
mode: "line" | "file",
|
|
315
|
+
): string[] {
|
|
316
|
+
const args = [
|
|
317
|
+
"api",
|
|
318
|
+
`repos/${target.repo}/pulls/${target.number}/comments`,
|
|
319
|
+
"-f",
|
|
320
|
+
`body=${buildCommentBody(finding)}`,
|
|
321
|
+
"-f",
|
|
322
|
+
`path=${finding.file}`,
|
|
323
|
+
];
|
|
324
|
+
if (mode === "line") {
|
|
325
|
+
args.push("-F", `line=${finding.line}`, "-f", "side=RIGHT");
|
|
326
|
+
} else {
|
|
327
|
+
// Sem linha específica: ancora no arquivo (comentário de nível de arquivo).
|
|
328
|
+
args.push("-f", "subject_type=file");
|
|
329
|
+
}
|
|
330
|
+
if (target.headSha) args.push("-f", `commit_id=${target.headSha}`);
|
|
331
|
+
return args;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Posta o comentário de review de um finding (na linha ou no arquivo). */
|
|
335
|
+
export function postComment(
|
|
336
|
+
runner: GhRunner,
|
|
337
|
+
target: PrTarget,
|
|
338
|
+
finding: FindingState,
|
|
339
|
+
mode: "line" | "file",
|
|
340
|
+
): PostResult {
|
|
341
|
+
return parseComment(runner(commentArgs(target, finding, mode)));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Posta o comentário geral (fallback) com os findings sem posição no diff. */
|
|
345
|
+
export function postGeneral(
|
|
346
|
+
runner: GhRunner,
|
|
347
|
+
target: PrTarget,
|
|
348
|
+
findings: readonly FindingState[],
|
|
349
|
+
): PostResult {
|
|
350
|
+
return parseComment(
|
|
351
|
+
runner([
|
|
352
|
+
"api",
|
|
353
|
+
`repos/${target.repo}/issues/${target.number}/comments`,
|
|
354
|
+
"-f",
|
|
355
|
+
`body=${renderGeneralBody(findings)}`,
|
|
356
|
+
]),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Envia os findings selecionados: um comentário por finding — na linha do diff
|
|
362
|
+
* ou no arquivo (`subject_type=file`). O que não colar em nenhum dos dois vai
|
|
363
|
+
* para um único comentário geral. Muta o `state` com o vínculo
|
|
364
|
+
* (`finding.github`); o caller persiste.
|
|
365
|
+
*/
|
|
366
|
+
export function submitFindings(options: {
|
|
367
|
+
state: FindingsStateFile;
|
|
368
|
+
target: PrTarget;
|
|
369
|
+
runner: GhRunner;
|
|
370
|
+
selection?: SubmitSelection;
|
|
371
|
+
now?: string;
|
|
372
|
+
}): SubmitOutcome {
|
|
373
|
+
const { state, target, runner } = options;
|
|
374
|
+
const now = options.now ?? new Date().toISOString();
|
|
375
|
+
const candidates = selectSubmitCandidates(state, options.selection);
|
|
376
|
+
|
|
377
|
+
const outcome: SubmitOutcome = { posted: [], failed: [], skipped: [] };
|
|
378
|
+
const deferred: FindingState[] = [];
|
|
379
|
+
|
|
380
|
+
const link = (
|
|
381
|
+
finding: FindingState,
|
|
382
|
+
commentId: number,
|
|
383
|
+
url: string | undefined,
|
|
384
|
+
kind: "line" | "file" | "comment",
|
|
385
|
+
): void => {
|
|
386
|
+
finding.github = { commentId, url, kind, pr: target.number, at: now };
|
|
387
|
+
finding.updatedAt = now;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
for (const candidate of candidates) {
|
|
391
|
+
const finding = state.findings[candidate.id];
|
|
392
|
+
if (!finding) continue;
|
|
393
|
+
|
|
394
|
+
if (candidate.skip) {
|
|
395
|
+
outcome.skipped.push({ id: candidate.id, reason: "já enviado." });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Tenta a linha (quando há) e depois o arquivo — o primeiro que colar vence.
|
|
400
|
+
const modes: ("line" | "file")[] =
|
|
401
|
+
candidate.kind === "line" ? ["line", "file"] : ["file"];
|
|
402
|
+
let posted: PostResult | null = null;
|
|
403
|
+
let reason = "";
|
|
404
|
+
for (const mode of modes) {
|
|
405
|
+
const attempt = postComment(runner, target, finding, mode);
|
|
406
|
+
if (attempt.ok) {
|
|
407
|
+
posted = attempt;
|
|
408
|
+
link(finding, attempt.commentId, attempt.url, mode);
|
|
409
|
+
outcome.posted.push({
|
|
410
|
+
id: candidate.id,
|
|
411
|
+
kind: mode,
|
|
412
|
+
commentId: attempt.commentId,
|
|
413
|
+
url: attempt.url,
|
|
414
|
+
});
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
reason = attempt.reason;
|
|
418
|
+
}
|
|
419
|
+
if (!posted) {
|
|
420
|
+
outcome.failed.push({ id: candidate.id, reason });
|
|
421
|
+
deferred.push(finding);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (deferred.length > 0) {
|
|
426
|
+
const general = postGeneral(runner, target, deferred);
|
|
427
|
+
if (general.ok) {
|
|
428
|
+
for (const finding of deferred) {
|
|
429
|
+
link(finding, general.commentId, general.url, "comment");
|
|
430
|
+
outcome.posted.push({
|
|
431
|
+
id: finding.id,
|
|
432
|
+
kind: "comment",
|
|
433
|
+
commentId: general.commentId,
|
|
434
|
+
url: general.url,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
outcome.general = {
|
|
438
|
+
commentId: general.commentId,
|
|
439
|
+
url: general.url,
|
|
440
|
+
ids: deferred.map((finding) => finding.id),
|
|
441
|
+
};
|
|
442
|
+
} else {
|
|
443
|
+
for (const finding of deferred) {
|
|
444
|
+
if (!outcome.failed.some((entry) => entry.id === finding.id)) {
|
|
445
|
+
outcome.failed.push({ id: finding.id, reason: general.reason });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return outcome;
|
|
452
|
+
}
|
|
@@ -127,6 +127,57 @@ function checkRulePolarity(config: HoundsmanConfig, warnings: string[]): void {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Normaliza o texto da regra para detectar duplicatas (case/espaços). */
|
|
131
|
+
function normalizeRuleText(text: string): string {
|
|
132
|
+
return text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Aviso para regra repetida (texto normalizado) no mesmo watchdog — a regra deve
|
|
137
|
+
* existir uma única vez em `rules` (§ Convenções de escrita, item 10).
|
|
138
|
+
*/
|
|
139
|
+
function checkRuleDuplication(config: HoundsmanConfig, warnings: string[]): void {
|
|
140
|
+
for (const id of config.watchdogIds) {
|
|
141
|
+
const def = config.watchdogs[id];
|
|
142
|
+
if (!def) continue;
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
def.rules.forEach((rule, index) => {
|
|
145
|
+
const key = normalizeRuleText(rule.text);
|
|
146
|
+
if (!key) return;
|
|
147
|
+
if (seen.has(key)) {
|
|
148
|
+
warnings.push(`Regra duplicada em ${id} (rule-${index}): ${rule.text}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
seen.add(key);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Campos de metadados que NÃO devem conter texto de regra. */
|
|
157
|
+
const RULE_BEARING_FIELDS = ["reviewFocus", "contextFiles", "exclude"] as const;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Aviso quando `reviewFocus`/`contextFiles`/`exclude` recebem texto de regra
|
|
161
|
+
* (prefixo de polaridade) — a regra vive só em `rules`; duplicá-la nesses campos
|
|
162
|
+
* deixa o brief do subagente redundante.
|
|
163
|
+
*/
|
|
164
|
+
function checkMetadataPrecision(config: HoundsmanConfig, warnings: string[]): void {
|
|
165
|
+
for (const id of config.watchdogIds) {
|
|
166
|
+
const def = config.watchdogs[id];
|
|
167
|
+
if (!def) continue;
|
|
168
|
+
for (const field of RULE_BEARING_FIELDS) {
|
|
169
|
+
const items = def[field];
|
|
170
|
+
if (!items) continue;
|
|
171
|
+
items.forEach((item, index) => {
|
|
172
|
+
if (!POLARITY_PREFIX.test(item.trim())) return;
|
|
173
|
+
warnings.push(
|
|
174
|
+
`Texto de regra em ${id}.${field}[${index}] — a regra vive só em 'rules': ${item}`,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
130
181
|
/** Aviso para orçamento/concorrência inválidos — o motor cai no default. */
|
|
131
182
|
function checkBudgets(config: HoundsmanConfig, warnings: string[]): void {
|
|
132
183
|
const maxFiles = config.budgets?.maxFilesPerBrief;
|
|
@@ -153,6 +204,8 @@ export function checkRegistry(config: HoundsmanConfig): {
|
|
|
153
204
|
checkClassification(config, problems);
|
|
154
205
|
checkRendering(config, problems);
|
|
155
206
|
checkRulePolarity(config, warnings);
|
|
207
|
+
checkRuleDuplication(config, warnings);
|
|
208
|
+
checkMetadataPrecision(config, warnings);
|
|
156
209
|
checkBudgets(config, warnings);
|
|
157
210
|
const jevQuestions = checkJevSpecs(config, problems);
|
|
158
211
|
|