@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -3
  3. package/assets/houndsman.png +0 -0
  4. package/assets/logo-legacy.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/package.json +64 -5
  7. package/src/cli/index.ts +290 -0
  8. package/src/core/assets.ts +28 -0
  9. package/src/core/brief.ts +185 -0
  10. package/src/core/commands/context.ts +39 -0
  11. package/src/core/commands/findings.ts +390 -0
  12. package/src/core/commands/review.ts +50 -0
  13. package/src/core/commands/scaffold.ts +393 -0
  14. package/src/core/commands/setup.ts +75 -0
  15. package/src/core/commands/test.ts +20 -0
  16. package/src/core/commands/update.ts +30 -0
  17. package/src/core/commands/watchdog.ts +105 -0
  18. package/src/core/config-loader.ts +87 -0
  19. package/src/core/config.ts +101 -0
  20. package/src/core/diff-source.ts +140 -0
  21. package/src/core/doc-discoverer.ts +33 -0
  22. package/src/core/file-classifier.ts +295 -0
  23. package/src/core/finding-anchor.ts +147 -0
  24. package/src/core/findings-state.ts +538 -0
  25. package/src/core/jev/index.ts +12 -0
  26. package/src/core/jev/jev-candidates.ts +105 -0
  27. package/src/core/jev/jev-client.ts +160 -0
  28. package/src/core/jev/jev-detector.ts +205 -0
  29. package/src/core/jev/jev-questions.ts +92 -0
  30. package/src/core/jev/jev-types.ts +91 -0
  31. package/src/core/orchestrator.ts +596 -0
  32. package/src/core/paths.ts +33 -0
  33. package/src/core/pr-context.ts +271 -0
  34. package/src/core/registry-check.ts +160 -0
  35. package/src/core/report-builder.ts +461 -0
  36. package/src/core/types.ts +147 -0
  37. package/src/core/watchdog-registry.ts +43 -0
  38. package/src/index.ts +132 -0
  39. package/src/server/artifacts.ts +66 -0
  40. package/src/server/client/App.tsx +158 -0
  41. package/src/server/client/ArtifactsPanel.tsx +48 -0
  42. package/src/server/client/Filters.tsx +83 -0
  43. package/src/server/client/FindingCard.tsx +122 -0
  44. package/src/server/client/FindingsList.tsx +54 -0
  45. package/src/server/client/RunList.tsx +43 -0
  46. package/src/server/client/SummaryBar.tsx +21 -0
  47. package/src/server/client/api.ts +37 -0
  48. package/src/server/client/constants.ts +63 -0
  49. package/src/server/client/index.tsx +12 -0
  50. package/src/server/client/types.ts +48 -0
  51. package/src/server/client-build.ts +47 -0
  52. package/src/server/index.ts +365 -0
  53. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  54. package/src/skill/OUTPUT-FORMAT.md +70 -0
  55. package/src/skill/README.md +124 -0
  56. package/src/skill/SETUP.md +101 -0
  57. package/src/skill/example-review.md +58 -0
  58. package/src/skill/houndsman-update.md +251 -0
  59. package/src/skill/houndsman.md +316 -0
  60. package/src/tests/findings-anchor.test.ts +120 -0
  61. package/src/tests/findings-commands.test.ts +226 -0
  62. package/src/tests/findings-state.test.ts +274 -0
  63. package/src/tests/fixtures/fake-project.ts +64 -0
  64. package/src/tests/review-scripts.test.ts +1150 -0
  65. package/src/tests/server.test.ts +212 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Consolidação dos relatórios JSON dos watchdogs em um markdown (biblioteca).
3
+ *
4
+ * Entrypoint: `cli/review.ts` (via `core/orchestrator.ts`). Deduplica achados
5
+ * por (file, line, rule), agrupa por severidade e renderiza a tabela de cobertura.
6
+ *
7
+ * Schema dos watchdogs: asset `OUTPUT-FORMAT.md` (embarcado em `src/skill/`).
8
+ */
9
+
10
+ import type { JevCandidate } from "./jev";
11
+ import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity, severityRank } from "./types";
12
+
13
+ /** Resolve o nome legível de um watchdog (default: identidade). */
14
+ export type WatchdogNameResolver = (watchdogId: string) => string;
15
+
16
+ const identityName: WatchdogNameResolver = (id) => id;
17
+
18
+ export type Finding = {
19
+ /** Id curto do finding (preenchido no `--finalize`; usado pela triagem). */
20
+ id?: string;
21
+ rule: string;
22
+ severity: Severity;
23
+ file: string;
24
+ line?: number;
25
+ message: string;
26
+ action: string;
27
+ evidence?: string;
28
+ };
29
+
30
+ export type WatchdogReport = {
31
+ watchdogId: string;
32
+ reviewedFiles: string[];
33
+ skipped: string[];
34
+ findings: Finding[];
35
+ };
36
+
37
+ /** Achado deduplicado, com todos os watchdogs que o apontaram. */
38
+ export type MergedFinding = Finding & {
39
+ watchdogs: string[];
40
+ };
41
+
42
+ /** Resumo do contexto da PR (objetivo/motivação/resumo das alterações), gravado pelo subagente. */
43
+ export type ContextSummary = {
44
+ available: boolean;
45
+ source?: {
46
+ number?: number;
47
+ title?: string;
48
+ url?: string;
49
+ base?: string;
50
+ head?: string;
51
+ };
52
+ objective?: string;
53
+ motivation?: string;
54
+ changeSummary?: string[];
55
+ };
56
+
57
+ const VALID_SEVERITIES = new Set<string>(SEVERITY_ORDER);
58
+
59
+ /**
60
+ * Limites do resumo da PR: mantém o relatório e os briefs enxutos — evita
61
+ * confundir o watchdog humano e estourar o contexto da IA watchdog.
62
+ */
63
+ const CONTEXT_LIMITS = {
64
+ objective: 200,
65
+ motivation: 240,
66
+ changeSummaryItem: 160,
67
+ changeSummaryItems: 4,
68
+ } as const;
69
+
70
+ /** Corta `value` em `max` caracteres, sinalizando com reticências. */
71
+ function truncate(value: string, max: number): string {
72
+ return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}…`;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Helpers exportados (testáveis)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** `feature/add-webhook` → `feature-add-webhook`. */
80
+ export function sanitizeBranch(branch: string): string {
81
+ return branch
82
+ .trim()
83
+ .replace(/[/\\]+/g, "-")
84
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
85
+ .replace(/^-+|-+$/g, "")
86
+ .toLowerCase();
87
+ }
88
+
89
+ function isFinding(value: unknown): value is Finding {
90
+ if (typeof value !== "object" || value === null) return false;
91
+ const f = value as Record<string, unknown>;
92
+ return (
93
+ typeof f.rule === "string" &&
94
+ typeof f.severity === "string" &&
95
+ VALID_SEVERITIES.has(f.severity) &&
96
+ typeof f.file === "string" &&
97
+ typeof f.message === "string" &&
98
+ typeof f.action === "string" &&
99
+ (f.line === undefined || typeof f.line === "number")
100
+ );
101
+ }
102
+
103
+ /** Valida e normaliza um relatório. Retorna null se inválido. */
104
+ export function parseWatchdogReport(raw: unknown, source: string): WatchdogReport | null {
105
+ if (typeof raw !== "object" || raw === null) {
106
+ console.error(`[report-builder] ${source}: não é um objeto JSON`);
107
+ return null;
108
+ }
109
+ const obj = raw as Record<string, unknown>;
110
+ if (typeof obj.watchdogId !== "string") {
111
+ console.error(`[report-builder] ${source}: campo 'watchdogId' ausente`);
112
+ return null;
113
+ }
114
+ if (!Array.isArray(obj.findings)) {
115
+ console.error(`[report-builder] ${source}: campo 'findings' ausente`);
116
+ return null;
117
+ }
118
+
119
+ const findings = obj.findings.filter((f) => {
120
+ if (!isFinding(f)) {
121
+ console.error(`[report-builder] ${source}: finding inválido ignorado`);
122
+ return false;
123
+ }
124
+ return true;
125
+ });
126
+
127
+ return {
128
+ watchdogId: obj.watchdogId,
129
+ reviewedFiles: Array.isArray(obj.reviewedFiles)
130
+ ? (obj.reviewedFiles as string[])
131
+ : [],
132
+ skipped: Array.isArray(obj.skipped) ? (obj.skipped as string[]) : [],
133
+ findings,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Valida/normaliza o `context-summary.json` do subagente de contexto.
139
+ * Retorna null se indisponível ou sem conteúdo útil — a seção é omitida.
140
+ * Aplica limites de tamanho: o resumo precisa ser curto para não confundir o
141
+ * watchdog humano nem estourar o contexto da IA watchdog (briefs + relatório).
142
+ */
143
+ export function parseContextSummary(raw: unknown): ContextSummary | null {
144
+ if (typeof raw !== "object" || raw === null) return null;
145
+ const obj = raw as Record<string, unknown>;
146
+ if (obj.available !== true) return null;
147
+
148
+ const text = (value: unknown, max = Number.POSITIVE_INFINITY): string | undefined => {
149
+ const trimmed = typeof value === "string" ? value.trim() : "";
150
+ return trimmed.length > 0 ? truncate(trimmed, max) : undefined;
151
+ };
152
+ const objective = text(obj.objective, CONTEXT_LIMITS.objective);
153
+ const motivation = text(obj.motivation, CONTEXT_LIMITS.motivation);
154
+ const changeSummary = (Array.isArray(obj.changeSummary) ? obj.changeSummary : [])
155
+ .map((item) => text(item, CONTEXT_LIMITS.changeSummaryItem))
156
+ .filter((item): item is string => item !== undefined)
157
+ .slice(0, CONTEXT_LIMITS.changeSummaryItems);
158
+
159
+ if (!objective && !motivation && changeSummary.length === 0) return null;
160
+
161
+ const src = (obj.source ?? {}) as Record<string, unknown>;
162
+ const source = {
163
+ number: typeof src.number === "number" ? src.number : undefined,
164
+ title: text(src.title),
165
+ url: text(src.url),
166
+ base: text(src.base),
167
+ head: text(src.head),
168
+ };
169
+
170
+ return { available: true, source, objective, motivation, changeSummary };
171
+ }
172
+
173
+ /** Deduplica achados por (file, line, rule), mesclando atribuição de watchdogs. */
174
+ export function dedupeFindings(reports: WatchdogReport[]): MergedFinding[] {
175
+ const map = new Map<string, MergedFinding>();
176
+ for (const report of reports) {
177
+ for (const finding of report.findings) {
178
+ const key = `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
179
+ const existing = map.get(key);
180
+ if (existing) {
181
+ if (!existing.watchdogs.includes(report.watchdogId)) {
182
+ existing.watchdogs.push(report.watchdogId);
183
+ }
184
+ // Mantém a versão de maior severidade (severidade + detalhes)
185
+ if (severityRank(finding.severity) < severityRank(existing.severity)) {
186
+ existing.severity = finding.severity;
187
+ existing.message = finding.message;
188
+ existing.action = finding.action;
189
+ existing.evidence = finding.evidence ?? existing.evidence;
190
+ }
191
+ continue;
192
+ }
193
+ map.set(key, { ...finding, watchdogs: [report.watchdogId] });
194
+ }
195
+ }
196
+ return [...map.values()].sort((a, b) => {
197
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
198
+ if (bySeverity !== 0) return bySeverity;
199
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
200
+ return (a.line ?? 0) - (b.line ?? 0);
201
+ });
202
+ }
203
+
204
+ function watchdogName(nameOf: WatchdogNameResolver, id: string): string {
205
+ return nameOf(id);
206
+ }
207
+
208
+ function renderFinding(finding: MergedFinding, nameOf: WatchdogNameResolver): string[] {
209
+ const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
210
+ const watchdogs = finding.watchdogs.map((r) => watchdogName(nameOf, r)).join(", ");
211
+ const id = finding.id ? ` · \`${finding.id}\`` : "";
212
+ const lines = [
213
+ `- **${finding.rule}** — \`${location}\` · _${watchdogs}_${id}`,
214
+ ` ${finding.message}`,
215
+ ` **Ação:** ${finding.action}`,
216
+ ];
217
+ if (finding.evidence) {
218
+ lines.push(` **Evidência:** \`${finding.evidence}\``);
219
+ }
220
+ lines.push("");
221
+ return lines;
222
+ }
223
+
224
+ function renderCoverage(
225
+ reports: WatchdogReport[],
226
+ coverage: { classified?: Record<string, string[]> },
227
+ nameOf: WatchdogNameResolver,
228
+ ): string[] {
229
+ const lines = [
230
+ "## Cobertura",
231
+ "",
232
+ "| Watchdog | Arquivos revisados | Findings |",
233
+ "|---|---|---|",
234
+ ];
235
+ for (const report of reports) {
236
+ const expected = coverage.classified?.[report.watchdogId];
237
+ const reviewed = new Set(report.reviewedFiles);
238
+ const pending = expected ? expected.filter((f) => !reviewed.has(f)) : [];
239
+ const reviewedLabel =
240
+ expected && pending.length > 0
241
+ ? `${report.reviewedFiles.length}/${expected.length} (faltam ${pending.length})`
242
+ : `${report.reviewedFiles.length}`;
243
+ lines.push(
244
+ `| ${watchdogName(nameOf, report.watchdogId)} | ${reviewedLabel} | ${report.findings.length} |`,
245
+ );
246
+ }
247
+ lines.push("");
248
+ return lines;
249
+ }
250
+
251
+ /** Candidatos Jev não confirmados por nenhum watchdog — revisão manual, não bloqueiam. */
252
+ function renderUnconfirmedCandidates(
253
+ candidates: JevCandidate[],
254
+ merged: MergedFinding[],
255
+ ): string[] {
256
+ const confirmed = new Set(merged.map((f) => `${f.file}|${f.rule}`));
257
+ const pending = candidates.filter((c) => !confirmed.has(`${c.file}|${c.rule}`));
258
+ if (pending.length === 0) return [];
259
+ const lines = [
260
+ "## 🔎 Candidatos não verificados (Jev)",
261
+ "",
262
+ "> Indícios automáticos que nenhum watchdog confirmou. Revise manualmente — não bloqueiam.",
263
+ "",
264
+ ];
265
+ for (const c of pending) {
266
+ lines.push(
267
+ `- \`${c.file}\` · **${c.severity}** · confiança ${c.confidence.toFixed(2)} · _${c.watchdogId}_`,
268
+ ` ${c.rule}`,
269
+ "",
270
+ );
271
+ }
272
+ return lines;
273
+ }
274
+
275
+ function renderConclusion(counts: Record<Severity, number>): string[] {
276
+ const status =
277
+ counts.blocking > 0
278
+ ? "❌ Mudanças Necessárias"
279
+ : counts.attention > 0
280
+ ? "⚠️ Aprovado com Ressalvas"
281
+ : "✅ Aprovado";
282
+ return [
283
+ "## Conclusão",
284
+ "",
285
+ `**Status:** ${status}`,
286
+ `**Resumo:** ${counts.blocking} violações bloqueantes, ${counts.attention} pontos de atenção, ${counts.suggestion} sugestões e ${counts.nit} nits.`,
287
+ ];
288
+ }
289
+
290
+ /**
291
+ * Agrupa os achados por arquivo (navegabilidade em PRs grandes) e renderiza cada
292
+ * bloco. O `merged` já vem ordenado por severidade/arquivo/linha.
293
+ */
294
+ function renderFindingsByFile(
295
+ findings: readonly MergedFinding[],
296
+ nameOf: WatchdogNameResolver,
297
+ ): string[] {
298
+ const byFile = new Map<string, MergedFinding[]>();
299
+ for (const finding of findings) {
300
+ const list = byFile.get(finding.file);
301
+ if (list) list.push(finding);
302
+ else byFile.set(finding.file, [finding]);
303
+ }
304
+
305
+ const lines: string[] = [];
306
+ for (const [file, items] of byFile) {
307
+ lines.push(`### \`${file}\``, "");
308
+ for (const item of items) lines.push(...renderFinding(item, nameOf));
309
+ }
310
+ return lines;
311
+ }
312
+
313
+ /** Seções por severidade (❌ > ⚠️ > 💡 > 🔎), com achados agrupados por arquivo. */
314
+ function renderFindingsSections(
315
+ merged: readonly MergedFinding[],
316
+ nameOf: WatchdogNameResolver,
317
+ ): string[] {
318
+ const lines: string[] = [];
319
+ for (const severity of SEVERITY_ORDER) {
320
+ const items = merged.filter((f) => f.severity === severity);
321
+ if (items.length === 0) continue;
322
+ lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
323
+ lines.push(...renderFindingsByFile(items, nameOf));
324
+ }
325
+ return lines;
326
+ }
327
+
328
+ /** Relatório de domínio gravado à parte (um por watchdog com achados). */
329
+ export type DomainReport = {
330
+ id: string;
331
+ name: string;
332
+ /** Nome do arquivo do relatório (relativo ao diretório de artefatos). */
333
+ file: string;
334
+ findings: number;
335
+ reviewedFiles: number;
336
+ expectedFiles: number;
337
+ };
338
+
339
+ /** Índice dos relatórios por domínio (seção do `review.md` consolidado). */
340
+ function renderDomainIndex(domains: readonly DomainReport[]): string[] {
341
+ if (domains.length === 0) return [];
342
+ const lines = [
343
+ "## Relatórios por domínio",
344
+ "",
345
+ "| Watchdog | Achados | Revisados | Relatório |",
346
+ "|---|---|---|---|",
347
+ ];
348
+ for (const domain of domains) {
349
+ lines.push(
350
+ `| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | [\`${domain.file}\`](${domain.file}) |`,
351
+ );
352
+ }
353
+ lines.push("");
354
+ return lines;
355
+ }
356
+
357
+ /**
358
+ * Relatório isolado de um watchdog — mantém o `review.md` enxuto em PRs grandes.
359
+ */
360
+ export function renderDomainReport(
361
+ branch: string,
362
+ report: WatchdogReport,
363
+ merged: MergedFinding[],
364
+ expectedFiles: number,
365
+ context?: ContextSummary | null,
366
+ nameOf: WatchdogNameResolver = identityName,
367
+ ): string {
368
+ const now = new Date().toISOString().slice(0, 10);
369
+ const name = watchdogName(nameOf, report.watchdogId);
370
+ const lines: string[] = [
371
+ `# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
372
+ "",
373
+ `**Data:** ${now}`,
374
+ `**Arquivos revisados:** ${report.reviewedFiles.length}/${expectedFiles}`,
375
+ `**Achados:** ${merged.length}`,
376
+ "",
377
+ ];
378
+
379
+ if (context?.available) lines.push(...renderContext(context));
380
+
381
+ lines.push(...renderFindingsSections(merged, nameOf));
382
+ if (merged.length === 0) lines.push("Nenhum achado. ✅", "");
383
+
384
+ if (report.skipped.length > 0) {
385
+ lines.push(
386
+ "## Não revisados",
387
+ "",
388
+ ...report.skipped.map((item) => `- \`${item}\``),
389
+ "",
390
+ );
391
+ }
392
+
393
+ return lines.join("\n");
394
+ }
395
+
396
+ /** Seção de contexto da PR (Objetivo / Motivação / Resumo das alterações). */
397
+ function renderContext(context: ContextSummary): string[] {
398
+ const lines = ["## Contexto da PR", ""];
399
+ const src = context.source;
400
+
401
+ const ref = [src?.number ? `#${src.number}` : "", src?.title ?? ""]
402
+ .filter(Boolean)
403
+ .join(" ");
404
+ if (ref) lines.push(`**PR:** ${ref}${src?.url ? ` — ${src.url}` : ""}`);
405
+ if (src?.head && src?.base)
406
+ lines.push(`**Branch:** \`${src.head}\` → \`${src.base}\``);
407
+ if (context.objective) lines.push(`**Objetivo:** ${context.objective}`);
408
+ if (context.motivation) lines.push(`**Motivação:** ${context.motivation}`);
409
+ if (context.changeSummary && context.changeSummary.length > 0) {
410
+ lines.push("", "**Resumo das alterações:**", "");
411
+ for (const item of context.changeSummary) lines.push(`- ${item}`);
412
+ }
413
+ lines.push("");
414
+ return lines;
415
+ }
416
+
417
+ export function renderReport(
418
+ branch: string,
419
+ reports: WatchdogReport[],
420
+ merged: MergedFinding[],
421
+ coverage: { classified?: Record<string, string[]> },
422
+ context?: ContextSummary | null,
423
+ candidates?: JevCandidate[],
424
+ nameOf: WatchdogNameResolver = identityName,
425
+ domains: readonly DomainReport[] = [],
426
+ ): string {
427
+ const now = new Date().toISOString().slice(0, 10);
428
+ const counts = {
429
+ blocking: merged.filter((f) => f.severity === "blocking").length,
430
+ attention: merged.filter((f) => f.severity === "attention").length,
431
+ suggestion: merged.filter((f) => f.severity === "suggestion").length,
432
+ nit: merged.filter((f) => f.severity === "nit").length,
433
+ };
434
+
435
+ const lines: string[] = [
436
+ `# PR Review — ${branch}`,
437
+ "",
438
+ `**Data:** ${now}`,
439
+ `**Watchdogs ativos:** ${reports.map((r) => r.watchdogId).join(", ") || "—"}`,
440
+ `**Achados:** ${counts.blocking} bloqueantes · ${counts.attention} atenção · ${counts.suggestion} sugestões · ${counts.nit} nits`,
441
+ "",
442
+ ];
443
+
444
+ if (context) lines.push(...renderContext(context));
445
+
446
+ lines.push(...renderFindingsSections(merged, nameOf));
447
+
448
+ if (merged.length === 0) {
449
+ lines.push("Nenhum achado. ✅", "");
450
+ }
451
+
452
+ if (candidates && candidates.length > 0) {
453
+ lines.push(...renderUnconfirmedCandidates(candidates, merged));
454
+ }
455
+
456
+ lines.push(...renderDomainIndex(domains));
457
+ lines.push(...renderCoverage(reports, coverage, nameOf));
458
+ lines.push(...renderConclusion(counts));
459
+
460
+ return lines.join("\n");
461
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tipos e constantes genéricas do motor de revisão (houndsman).
3
+ *
4
+ * Sem dependência do projeto: os ids de watchdog são derivados do
5
+ * `HoundsmanConfig` injetado pelo consumidor. `Severity` é a escala de achados;
6
+ * `Rule` e `WatchdogMeta` descrevem as regras consumidas pela renderização.
7
+ */
8
+
9
+ import type { JevRuleSpec } from "./jev/jev-types";
10
+
11
+ /** Severidade de um achado. `blocking` bloqueia o merge. */
12
+ export type Severity = "blocking" | "attention" | "suggestion" | "nit";
13
+
14
+ /** Ordem canônica de severidade (mais grave primeiro). */
15
+ export const SEVERITY_ORDER: readonly Severity[] = [
16
+ "blocking",
17
+ "attention",
18
+ "suggestion",
19
+ "nit",
20
+ ];
21
+
22
+ /** Emoji associado a cada severidade. */
23
+ export const SEVERITY_EMOJI: Record<Severity, string> = {
24
+ blocking: "❌",
25
+ attention: "⚠️",
26
+ suggestion: "💡",
27
+ nit: "🔎",
28
+ };
29
+
30
+ /**
31
+ * Regra do watchdog: severidade + texto. O texto declara a polaridade de forma
32
+ * explícita — `Promover:` (algo que DEVE acontecer) ou `Evitar:`/`Não`/`Nunca`
33
+ * (algo que NÃO DEVE acontecer).
34
+ *
35
+ * `jev` (opcional) é a spec de pré-análise: quando presente, a regra vira uma
36
+ * pergunta tipada enviada ao modelo no Passo 1.5.
37
+ */
38
+ export type Rule = {
39
+ severity: Severity;
40
+ text: string;
41
+ jev?: JevRuleSpec;
42
+ };
43
+
44
+ /**
45
+ * Entrada de classificação: como o watchdog casa um arquivo alterado.
46
+ *
47
+ * `value` é sempre um padrão (regex, estilo grep — fonte sem barras).
48
+ *
49
+ * - `filename` (padrão) → padrão aplicado ao caminho do arquivo.
50
+ * - `content` → padrão aplicado ao conteúdo do arquivo.
51
+ */
52
+ export type ClassifyRule = {
53
+ type?: "filename" | "content";
54
+ value: string;
55
+ };
56
+
57
+ export type WatchdogMeta = {
58
+ /** Nome legível do watchdog */
59
+ name: string;
60
+ /** Escopo de atuação (quais arquivos/pastas revisa) */
61
+ scope: string;
62
+ /** Arquivos que este watchdog cobre (classificação). */
63
+ classify?: readonly ClassifyRule[];
64
+ /** Regras do watchdog (texto com polaridade explícita: `Promover:`/`Evitar:`). */
65
+ rules: readonly Rule[];
66
+ /** Arquivos de documentação de contexto (caminhos relativos à raiz) */
67
+ contextFiles: string[];
68
+ };
69
+
70
+ /** Status de um arquivo alterado (git `--name-status`). */
71
+ export type FileStatus =
72
+ | "added"
73
+ | "modified"
74
+ | "deleted"
75
+ | "renamed"
76
+ | "copied"
77
+ | "unknown";
78
+
79
+ /** Status por arquivo, conforme o `classification.json`. */
80
+ export type FileStatusMap = Record<string, { status: FileStatus; oldPath?: string }>;
81
+
82
+ /** Linhas adicionadas/removidas de um arquivo no diff (churn). */
83
+ export type FileChurn = { additions: number; deletions: number };
84
+
85
+ /** Churn por caminho de arquivo (chave = caminho novo). */
86
+ export type ChurnMap = Record<string, FileChurn>;
87
+
88
+ /** Contexto da PR injetado no brief (estrutural — sem dependência de runtime). */
89
+ export type WatchdogRenderContext = {
90
+ objective?: string;
91
+ motivation?: string;
92
+ changeSummary?: string[];
93
+ };
94
+
95
+ /** Candidato pré-analisado (Jev) exibido no brief. */
96
+ export type WatchdogRenderCandidate = {
97
+ file: string;
98
+ severity: Severity;
99
+ confidence: number;
100
+ rule: string;
101
+ };
102
+
103
+ /** Dados que o renderer do brief recebe (sem dependência de runtime do jev/cli). */
104
+ export type WatchdogRenderArgs = {
105
+ watchdogId: string;
106
+ files: readonly string[];
107
+ statuses: FileStatusMap;
108
+ /** Churn por arquivo — insumo para priorizar a leitura (PRs grandes). */
109
+ churn?: ChurnMap;
110
+ /** Arquivos do domínio fora do orçamento — NÃO revisar (registrar em `skipped`). */
111
+ overflow?: readonly string[];
112
+ deleted: readonly string[];
113
+ branch: string;
114
+ outputPath: string;
115
+ outputFormatPath: string;
116
+ docs: readonly string[];
117
+ context?: WatchdogRenderContext | null;
118
+ candidates?: readonly WatchdogRenderCandidate[];
119
+ };
120
+
121
+ /** Definição completa de um watchdog — 1 por arquivo em `project/watchdogs/`. */
122
+ export type WatchdogDefinition = WatchdogMeta & {
123
+ /** Id do watchdog (deve casar com a chave em `WATCHDOG_DEFINITIONS`). */
124
+ id: string;
125
+ /** Uma frase sobre o propósito (ex-`Objetivo` do .md). */
126
+ description: string;
127
+ /** Watchdog deve checar breaking change em arquivos deletados do seu domínio. */
128
+ breakingChange?: boolean;
129
+ /** Prioridade de arquivos/paths (ex-`O Que Revisar`). Opcional. */
130
+ reviewFocus?: readonly string[];
131
+ /** O que NUNCA carregar/enviar (ex-`NÃO carregar`). Opcional. */
132
+ exclude?: readonly string[];
133
+ /** Render customizado do brief; se omitido usa `defaultRenderBrief`.
134
+ * Recebe a própria definição (`def`) para reutilizar `defaultRenderBrief`
135
+ * sem criar dependência de runtime `registry → core`. */
136
+ renderBrief?: (args: WatchdogRenderArgs, def: WatchdogDefinition) => string[];
137
+ };
138
+
139
+ /** Helper de autoria: tipa o objeto do watchdog (id contra `WatchdogId`). */
140
+ export function defineWatchdog(def: WatchdogDefinition): WatchdogDefinition {
141
+ return def;
142
+ }
143
+
144
+ /** Índice numérico da severidade (menor = mais grave). */
145
+ export function severityRank(severity: Severity): number {
146
+ return SEVERITY_ORDER.indexOf(severity);
147
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Acesso tipado aos watchdogs de um `HoundsmanConfig` (biblioteca).
3
+ *
4
+ * Substitui o antigo barrel `registry/`. O motor não conhece ids concretos —
5
+ * tudo chega via `config`.
6
+ */
7
+
8
+ import type { HoundsmanConfig } from "./config";
9
+ import type { WatchdogDefinition } from "./types";
10
+
11
+ /** Acesso tipado a um watchdog do projeto (id string em runtime). */
12
+ export function getWatchdog<TId extends string>(
13
+ config: HoundsmanConfig<TId>,
14
+ watchdogId: string,
15
+ ): WatchdogDefinition {
16
+ return config.watchdogs[watchdogId as TId];
17
+ }
18
+
19
+ /** Busca null-safe por id string (runtime). */
20
+ export function findWatchdog<TId extends string>(
21
+ config: HoundsmanConfig<TId>,
22
+ watchdogId: string,
23
+ ): WatchdogDefinition | null {
24
+ return config.watchdogs[watchdogId as TId] ?? null;
25
+ }
26
+
27
+ /** Contexto "essencial" do watchdog — SEM regras (docs/UI/inspeção). */
28
+ export function getWatchdogContext<TId extends string>(
29
+ config: HoundsmanConfig<TId>,
30
+ watchdogId: string,
31
+ ) {
32
+ const { id, name, description, scope, reviewFocus, exclude, contextFiles } =
33
+ getWatchdog(config, watchdogId);
34
+ return {
35
+ id,
36
+ name,
37
+ description,
38
+ scope,
39
+ reviewFocus,
40
+ exclude,
41
+ contextFiles,
42
+ };
43
+ }