@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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +189 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +423 -0
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. package/src/tests/server.test.ts +117 -0
@@ -0,0 +1,423 @@
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 { systemClock } from "./services/clock";
24
+ import { defaultGhRunner, type GhResult, type GhRunner } from "./services/gh-client";
25
+ import { SEVERITY_EMOJI, type Severity } from "./types";
26
+
27
+ export { type GhResult, type GhRunner, defaultGhRunner };
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Alvo (PR + repositório)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export type PrTarget = {
34
+ /** Número da PR. */
35
+ number: number;
36
+ /** `owner/name`. */
37
+ repo: string;
38
+ /** SHA do head revisado (base do `commit_id` dos comentários). */
39
+ headSha?: string;
40
+ url?: string;
41
+ };
42
+
43
+ export type TargetResult = { ok: true; target: PrTarget } | { ok: false; reason: string };
44
+
45
+ /** Extrai `owner/name` de uma URL de PR do GitHub. `null` se não casar. */
46
+ function repoFromUrl(url: string | undefined): string | null {
47
+ if (!url) return null;
48
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/);
49
+ return match ? `${match[1]}/${match[2]}` : null;
50
+ }
51
+
52
+ /** Mensagem curta para um erro do `gh`. */
53
+ function shortError(result: GhResult): string {
54
+ if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
55
+ return "gh não instalado.";
56
+ }
57
+ const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
58
+ if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
59
+ return last || `gh saiu com código ${result.code}.`;
60
+ }
61
+
62
+ /**
63
+ * Resolve a PR da branch + o repositório via `gh pr view`. Fail-open: devolve
64
+ * `{ ok:false, reason }` quando não há `gh`, login ou PR.
65
+ */
66
+ export function resolvePrTarget(runner: GhRunner, branch: string): TargetResult {
67
+ const view = runner(["pr", "view", branch, "--json", "number,url,headRefOid"]);
68
+ if (!view.ok) return { ok: false, reason: shortError(view) };
69
+
70
+ let payload: { number?: number; url?: string; headRefOid?: string };
71
+ try {
72
+ payload = JSON.parse(view.stdout) as typeof payload;
73
+ } catch {
74
+ return { ok: false, reason: "Resposta inválida do gh." };
75
+ }
76
+
77
+ const repo = repoFromUrl(payload.url);
78
+ if (!payload.number || !repo) {
79
+ return { ok: false, reason: "Não foi possível identificar a PR/repositório." };
80
+ }
81
+ return {
82
+ ok: true,
83
+ target: {
84
+ number: payload.number,
85
+ repo,
86
+ headSha: payload.headRefOid,
87
+ url: payload.url,
88
+ },
89
+ };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Seleção
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export type SubmitSelection = {
97
+ /** Severidades incluídas (vazio = todas). */
98
+ severities?: Severity[];
99
+ /** Estados de execução incluídos (vazio = todos). */
100
+ execStates?: FindingExecState[];
101
+ /** Decisões incluídas (default: `pending` + `execute` — nunca `ignore`). */
102
+ decisions?: FindingDecision[];
103
+ /** Reenvia findings já vinculados a um comentário. */
104
+ includeSent?: boolean;
105
+ };
106
+
107
+ /** Decisões elegíveis por padrão — o `ignore` (descartado na triagem) nunca sai. */
108
+ export const DEFAULT_SUBMIT_DECISIONS: readonly FindingDecision[] = [
109
+ "pending",
110
+ "execute",
111
+ ];
112
+
113
+ export type SubmitCandidate = {
114
+ id: string;
115
+ file: string;
116
+ line: number | null;
117
+ severity: Severity;
118
+ execState: FindingExecState;
119
+ rule: string;
120
+ message: string;
121
+ /** Onde o comentário será ancorado: na linha do diff ou no arquivo. */
122
+ kind: "line" | "file";
123
+ /** Já vinculado a um comentário remoto. */
124
+ sent: boolean;
125
+ /** URL do comentário já enviado (quando houver). */
126
+ sentUrl?: string;
127
+ /** Será pulado (já enviado e `includeSent` desligado). */
128
+ skip: boolean;
129
+ };
130
+
131
+ /**
132
+ * Seleciona os findings elegíveis ao envio: presentes, decisão elegível (sem
133
+ * `ignore`), casando os filtros de severidade/execução. Marca `sent`/`skip`.
134
+ */
135
+ export function selectSubmitCandidates(
136
+ state: FindingsStateFile,
137
+ selection: SubmitSelection = {},
138
+ ): SubmitCandidate[] {
139
+ const severities = new Set<Severity>(selection.severities ?? []);
140
+ const execStates = new Set<FindingExecState>(selection.execStates ?? []);
141
+ const decisions = new Set<FindingDecision>(
142
+ selection.decisions ?? DEFAULT_SUBMIT_DECISIONS,
143
+ );
144
+ const includeSent = selection.includeSent ?? false;
145
+
146
+ return Object.values(state.findings)
147
+ .filter((finding) => finding.present)
148
+ .filter((finding) => decisions.has(finding.decision))
149
+ .filter((finding) => severities.size === 0 || severities.has(finding.severity))
150
+ .filter((finding) => execStates.size === 0 || execStates.has(finding.execState))
151
+ .sort(compareFindings)
152
+ .map((finding) => {
153
+ const sent = Boolean(finding.github?.commentId);
154
+ return {
155
+ id: finding.id,
156
+ file: finding.file,
157
+ line: finding.line,
158
+ severity: finding.severity,
159
+ execState: finding.execState,
160
+ rule: finding.rule,
161
+ message: finding.message,
162
+ kind: finding.line !== null ? "line" : "file",
163
+ sent,
164
+ sentUrl: finding.github?.url,
165
+ skip: sent && !includeSent,
166
+ };
167
+ });
168
+ }
169
+
170
+ export type SubmitPreview = {
171
+ available: boolean;
172
+ reason?: string;
173
+ target?: PrTarget;
174
+ candidates: SubmitCandidate[];
175
+ totals: {
176
+ /** Total de candidatos (após os filtros). */
177
+ candidates: number;
178
+ /** Quantos serão efetivamente enviados. */
179
+ toSend: number;
180
+ /** Comentários ancorados na linha do diff. */
181
+ line: number;
182
+ /** Comentários ancorados no arquivo (sem linha específica). */
183
+ file: number;
184
+ /** Já enviados (no total de candidatos). */
185
+ sent: number;
186
+ };
187
+ };
188
+
189
+ /** Monta a prévia do envio a partir do estado + alvo resolvido. */
190
+ export function buildSubmitPreview(
191
+ state: FindingsStateFile,
192
+ target: TargetResult,
193
+ selection: SubmitSelection = {},
194
+ ): SubmitPreview {
195
+ const candidates = selectSubmitCandidates(state, selection);
196
+ const toSend = candidates.filter((candidate) => !candidate.skip);
197
+ return {
198
+ available: target.ok,
199
+ reason: target.ok ? undefined : target.reason,
200
+ target: target.ok ? target.target : undefined,
201
+ candidates,
202
+ totals: {
203
+ candidates: candidates.length,
204
+ toSend: toSend.length,
205
+ line: toSend.filter((candidate) => candidate.kind === "line").length,
206
+ file: toSend.filter((candidate) => candidate.kind === "file").length,
207
+ sent: candidates.filter((candidate) => candidate.sent).length,
208
+ },
209
+ };
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Corpo dos comentários
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** Corpo (markdown) do comentário inline de um finding. */
217
+ export function buildCommentBody(finding: FindingState): string {
218
+ const lines = [
219
+ `${SEVERITY_EMOJI[finding.severity]} **${finding.rule}** · _${finding.severity}_`,
220
+ "",
221
+ finding.message,
222
+ "",
223
+ `**Ação:** ${finding.action}`,
224
+ ];
225
+ if (finding.evidence) lines.push("", "```", finding.evidence, "```");
226
+ lines.push("", "_— houndsman_");
227
+ return lines.join("\n");
228
+ }
229
+
230
+ /** Corpo (markdown) do comentário geral com os findings sem posição no diff. */
231
+ export function renderGeneralBody(findings: readonly FindingState[]): string {
232
+ const lines = [
233
+ "## Revisão do houndsman",
234
+ "",
235
+ "Achados sem posição no diff (comentados aqui):",
236
+ "",
237
+ ];
238
+ for (const finding of findings) {
239
+ const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
240
+ lines.push(
241
+ `- ${SEVERITY_EMOJI[finding.severity]} **${finding.severity}** \`${location}\` — ${finding.rule}`,
242
+ ` ${finding.message}`,
243
+ );
244
+ }
245
+ return lines.join("\n");
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Envio
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export type PostedComment = {
253
+ id: string;
254
+ kind: "line" | "file" | "comment";
255
+ commentId: number;
256
+ url?: string;
257
+ };
258
+
259
+ export type SubmitOutcome = {
260
+ posted: PostedComment[];
261
+ failed: { id: string; reason: string }[];
262
+ skipped: { id: string; reason: string }[];
263
+ /** Comentário geral (quando houve findings sem posição/falha de inline). */
264
+ general?: { commentId: number; url?: string; ids: string[] };
265
+ };
266
+
267
+ type PostResult =
268
+ | { ok: true; commentId: number; url?: string }
269
+ | { ok: false; reason: string };
270
+
271
+ function parseComment(result: GhResult): PostResult {
272
+ if (!result.ok) return { ok: false, reason: shortError(result) };
273
+ try {
274
+ const payload = JSON.parse(result.stdout) as { id?: number; html_url?: string };
275
+ if (!payload.id) return { ok: false, reason: "resposta sem id." };
276
+ return { ok: true, commentId: payload.id, url: payload.html_url };
277
+ } catch {
278
+ return { ok: false, reason: "resposta inválida do gh." };
279
+ }
280
+ }
281
+
282
+ function commentArgs(
283
+ target: PrTarget,
284
+ finding: FindingState,
285
+ mode: "line" | "file",
286
+ ): string[] {
287
+ const args = [
288
+ "api",
289
+ `repos/${target.repo}/pulls/${target.number}/comments`,
290
+ "-f",
291
+ `body=${buildCommentBody(finding)}`,
292
+ "-f",
293
+ `path=${finding.file}`,
294
+ ];
295
+ if (mode === "line") {
296
+ args.push("-F", `line=${finding.line}`, "-f", "side=RIGHT");
297
+ } else {
298
+ // Sem linha específica: ancora no arquivo (comentário de nível de arquivo).
299
+ args.push("-f", "subject_type=file");
300
+ }
301
+ if (target.headSha) args.push("-f", `commit_id=${target.headSha}`);
302
+ return args;
303
+ }
304
+
305
+ /** Posta o comentário de review de um finding (na linha ou no arquivo). */
306
+ export function postComment(
307
+ runner: GhRunner,
308
+ target: PrTarget,
309
+ finding: FindingState,
310
+ mode: "line" | "file",
311
+ ): PostResult {
312
+ return parseComment(runner(commentArgs(target, finding, mode)));
313
+ }
314
+
315
+ /** Posta o comentário geral (fallback) com os findings sem posição no diff. */
316
+ export function postGeneral(
317
+ runner: GhRunner,
318
+ target: PrTarget,
319
+ findings: readonly FindingState[],
320
+ ): PostResult {
321
+ return parseComment(
322
+ runner([
323
+ "api",
324
+ `repos/${target.repo}/issues/${target.number}/comments`,
325
+ "-f",
326
+ `body=${renderGeneralBody(findings)}`,
327
+ ]),
328
+ );
329
+ }
330
+
331
+ /**
332
+ * Envia os findings selecionados: um comentário por finding — na linha do diff
333
+ * ou no arquivo (`subject_type=file`). O que não colar em nenhum dos dois vai
334
+ * para um único comentário geral. Muta o `state` com o vínculo
335
+ * (`finding.github`); o caller persiste.
336
+ */
337
+ export function submitFindings(options: {
338
+ state: FindingsStateFile;
339
+ target: PrTarget;
340
+ runner: GhRunner;
341
+ selection?: SubmitSelection;
342
+ now?: string;
343
+ }): SubmitOutcome {
344
+ const { state, target, runner } = options;
345
+ const now = options.now ?? systemClock();
346
+ const candidates = selectSubmitCandidates(state, options.selection);
347
+
348
+ const outcome: SubmitOutcome = { posted: [], failed: [], skipped: [] };
349
+ const deferred: FindingState[] = [];
350
+
351
+ const link = (
352
+ finding: FindingState,
353
+ commentId: number,
354
+ url: string | undefined,
355
+ kind: "line" | "file" | "comment",
356
+ ): void => {
357
+ finding.github = { commentId, url, kind, pr: target.number, at: now };
358
+ finding.updatedAt = now;
359
+ };
360
+
361
+ for (const candidate of candidates) {
362
+ const finding = state.findings[candidate.id];
363
+ if (!finding) continue;
364
+
365
+ if (candidate.skip) {
366
+ outcome.skipped.push({ id: candidate.id, reason: "já enviado." });
367
+ continue;
368
+ }
369
+
370
+ // Tenta a linha (quando há) e depois o arquivo — o primeiro que colar vence.
371
+ const modes: ("line" | "file")[] =
372
+ candidate.kind === "line" ? ["line", "file"] : ["file"];
373
+ let posted: PostResult | null = null;
374
+ let reason = "";
375
+ for (const mode of modes) {
376
+ const attempt = postComment(runner, target, finding, mode);
377
+ if (attempt.ok) {
378
+ posted = attempt;
379
+ link(finding, attempt.commentId, attempt.url, mode);
380
+ outcome.posted.push({
381
+ id: candidate.id,
382
+ kind: mode,
383
+ commentId: attempt.commentId,
384
+ url: attempt.url,
385
+ });
386
+ break;
387
+ }
388
+ reason = attempt.reason;
389
+ }
390
+ if (!posted) {
391
+ outcome.failed.push({ id: candidate.id, reason });
392
+ deferred.push(finding);
393
+ }
394
+ }
395
+
396
+ if (deferred.length > 0) {
397
+ const general = postGeneral(runner, target, deferred);
398
+ if (general.ok) {
399
+ for (const finding of deferred) {
400
+ link(finding, general.commentId, general.url, "comment");
401
+ outcome.posted.push({
402
+ id: finding.id,
403
+ kind: "comment",
404
+ commentId: general.commentId,
405
+ url: general.url,
406
+ });
407
+ }
408
+ outcome.general = {
409
+ commentId: general.commentId,
410
+ url: general.url,
411
+ ids: deferred.map((finding) => finding.id),
412
+ };
413
+ } else {
414
+ for (const finding of deferred) {
415
+ if (!outcome.failed.some((entry) => entry.id === finding.id)) {
416
+ outcome.failed.push({ id: finding.id, reason: general.reason });
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ return outcome;
423
+ }