@payconductor/houndsman 0.1.0 → 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 (45) hide show
  1. package/README.md +39 -78
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +89 -9
  6. package/src/core/assets.ts +8 -0
  7. package/src/core/brief.ts +25 -2
  8. package/src/core/commands/findings.ts +390 -0
  9. package/src/core/commands/scaffold.ts +20 -0
  10. package/src/core/config.ts +42 -0
  11. package/src/core/diff-source.ts +140 -0
  12. package/src/core/file-classifier.ts +50 -2
  13. package/src/core/finding-anchor.ts +147 -0
  14. package/src/core/findings-state.ts +538 -0
  15. package/src/core/jev/jev-client.ts +72 -11
  16. package/src/core/jev/jev-detector.ts +21 -19
  17. package/src/core/orchestrator.ts +169 -17
  18. package/src/core/paths.ts +3 -0
  19. package/src/core/registry-check.ts +15 -0
  20. package/src/core/report-builder.ts +113 -9
  21. package/src/core/types.ts +10 -0
  22. package/src/index.ts +78 -2
  23. package/src/server/artifacts.ts +66 -0
  24. package/src/server/client/App.tsx +158 -0
  25. package/src/server/client/ArtifactsPanel.tsx +48 -0
  26. package/src/server/client/Filters.tsx +83 -0
  27. package/src/server/client/FindingCard.tsx +122 -0
  28. package/src/server/client/FindingsList.tsx +54 -0
  29. package/src/server/client/RunList.tsx +43 -0
  30. package/src/server/client/SummaryBar.tsx +21 -0
  31. package/src/server/client/api.ts +37 -0
  32. package/src/server/client/constants.ts +63 -0
  33. package/src/server/client/index.tsx +12 -0
  34. package/src/server/client/types.ts +48 -0
  35. package/src/server/client-build.ts +47 -0
  36. package/src/server/index.ts +365 -0
  37. package/src/skill/README.md +14 -6
  38. package/src/skill/SETUP.md +5 -3
  39. package/src/skill/houndsman-update.md +1 -1
  40. package/src/skill/houndsman.md +87 -10
  41. package/src/tests/findings-anchor.test.ts +120 -0
  42. package/src/tests/findings-commands.test.ts +226 -0
  43. package/src/tests/findings-state.test.ts +274 -0
  44. package/src/tests/review-scripts.test.ts +299 -0
  45. package/src/tests/server.test.ts +212 -0
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
  import type { HoundsmanConfig } from "./config";
10
- import type { ClassifyRule, WatchdogDefinition } from "./types";
10
+ import { churnToRecord, collectChurn, type DiffSource } from "./diff-source";
11
+ import type { ChurnMap, ClassifyRule, WatchdogDefinition } from "./types";
11
12
 
12
13
  type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
13
14
 
@@ -22,6 +23,8 @@ export type ClassificationResult = {
22
23
  watchdogs: Record<string, string[]>;
23
24
  /** path → metadados (status, oldPath) */
24
25
  files: Record<string, { status: FileStatus; oldPath?: string }>;
26
+ /** path → linhas adicionadas/removidas (churn do diff) */
27
+ churn: ChurnMap;
25
28
  /** Arquivos não classificados (revisar manualmente) */
26
29
  unclassified: string[];
27
30
  /** Arquivos excluídos da revisão (gerados, locks, builds) */
@@ -167,6 +170,7 @@ export function parseEntries(raw: string): FileEntry[] {
167
170
  export function classifyEntries<TId extends string>(
168
171
  entries: FileEntry[],
169
172
  config: HoundsmanConfig<TId>,
173
+ churn: ChurnMap = {},
170
174
  ): ClassificationResult {
171
175
  const watchdogFiles: Record<string, string[]> = {};
172
176
  const files: ClassificationResult["files"] = {};
@@ -223,6 +227,7 @@ export function classifyEntries<TId extends string>(
223
227
  return {
224
228
  watchdogs,
225
229
  files,
230
+ churn,
226
231
  unclassified,
227
232
  excluded,
228
233
  deleted,
@@ -233,15 +238,58 @@ export function classifyEntries<TId extends string>(
233
238
 
234
239
  /**
235
240
  * Executa `git diff` entre base e head e classifica os arquivos alterados.
241
+ *
242
+ * `diff` (opcional) reaproveita um `DiffSource` já coletado — evita rodar o git
243
+ * de novo quando o chamador (ex.: `prepare`) precisa também do patch para o Jev.
236
244
  */
237
245
  export function classifyRange<TId extends string>(
238
246
  base: string,
239
247
  head: string,
240
248
  config: HoundsmanConfig<TId>,
249
+ diff?: DiffSource,
241
250
  ): ClassificationResult {
242
251
  const entries = parseEntries(runGitDiff(base, head));
243
- const result = classifyEntries(entries, config);
252
+ const churn = diff?.churn ?? collectChurn(base, head);
253
+ const result = classifyEntries(entries, config, churnToRecord(churn));
244
254
  result.base = base;
245
255
  result.head = head;
246
256
  return result;
247
257
  }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Triage (PRs grandes)
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** Churn total (adições + remoções) de um arquivo; 0 quando ausente. */
264
+ export function churnOf(filePath: string, churn: ChurnMap | undefined): number {
265
+ const entry = churn?.[filePath];
266
+ return entry ? entry.additions + entry.deletions : 0;
267
+ }
268
+
269
+ /** Ordena os arquivos por risco (churn desc; empate pelo caminho). */
270
+ export function sortByRisk(files: readonly string[], churn?: ChurnMap): string[] {
271
+ return [...files].sort((a, b) => {
272
+ const delta = churnOf(b, churn) - churnOf(a, churn);
273
+ return delta !== 0 ? delta : a.localeCompare(b);
274
+ });
275
+ }
276
+
277
+ /** Resultado da aplicação do orçamento: o que revisar e o que ficou de fora. */
278
+ export type BudgetSelection = { selected: string[]; overflow: string[] };
279
+
280
+ /**
281
+ * Aplica o orçamento por brief: mantém os `maxFiles` de maior risco e devolve o
282
+ * restante em `overflow` (nunca descarta silenciosamente). `maxFiles` não-positivo
283
+ * ou não-finito desativa o orçamento.
284
+ */
285
+ export function selectByBudget(
286
+ files: readonly string[],
287
+ churn: ChurnMap | undefined,
288
+ maxFiles: number,
289
+ ): BudgetSelection {
290
+ const ordered = sortByRisk(files, churn);
291
+ if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
292
+ return { selected: ordered, overflow: [] };
293
+ }
294
+ return { selected: ordered.slice(0, maxFiles), overflow: ordered.slice(maxFiles) };
295
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Identidade e reancoragem de findings (biblioteca).
3
+ *
4
+ * Um finding NÃO é identificado pela linha: a linha desloca após um auto-fix.
5
+ * A identidade é o `fingerprint` (arquivo + regra + âncora de conteúdo); a linha
6
+ * é apenas cache, recalculada varrendo o arquivo (`locateAnchor`).
7
+ *
8
+ * Âncora = linha ofensora normalizada + contexto (linha anterior/seguinte).
9
+ * Quando o fix altera a linha ofensora, a âncora some → o finding fica `stale`.
10
+ *
11
+ * Ocorrências byte-idênticas (mesmo snippet E mesmo contexto) compartilham o
12
+ * fingerprint: são tratadas como um grupo (limitação conhecida).
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+
17
+ /** Âncora de conteúdo de um finding (independente do número da linha). */
18
+ export type FindingAnchor = {
19
+ /** Linha ofensora normalizada. */
20
+ snippet: string;
21
+ /** Linha anterior normalizada (ausente se a ofensora for a primeira). */
22
+ contextBefore?: string;
23
+ /** Linha seguinte normalizada (ausente se a ofensora for a última). */
24
+ contextAfter?: string;
25
+ /** Última linha conhecida (1-based) — cache, nunca identidade. */
26
+ line: number;
27
+ };
28
+
29
+ /** Resultado da localização de uma âncora no conteúdo atual. */
30
+ export type AnchorLocation = {
31
+ /** Linha atual (1-based) ou null se a âncora não existe mais. */
32
+ line: number | null;
33
+ /** Nº de ocorrências da âncora no arquivo. */
34
+ occurrences: number;
35
+ };
36
+
37
+ /** Normaliza uma linha: colapsa espaços internos e apara as pontas. */
38
+ export function normalizeLine(raw: string): string {
39
+ return raw.replace(/\s+/g, " ").trim();
40
+ }
41
+
42
+ /** Conteúdo → linhas normalizadas (uma por linha do arquivo). */
43
+ export function normalizedLines(content: string): string[] {
44
+ return content.split(/\r?\n/).map(normalizeLine);
45
+ }
46
+
47
+ /** Marcador de contexto ausente (não confundir com linha vazia). */
48
+ const ABSENT = "\u0001";
49
+
50
+ /** Chave estável da âncora (snippet + contexto, SEM a linha). */
51
+ function anchorKey(anchor: FindingAnchor): string {
52
+ return [
53
+ anchor.snippet,
54
+ anchor.contextBefore ?? ABSENT,
55
+ anchor.contextAfter ?? ABSENT,
56
+ ].join("\u0000");
57
+ }
58
+
59
+ /** Hash estável (sha256 truncado) — base das identidades do motor. */
60
+ export function hashKey(input: string): string {
61
+ return createHash("sha256").update(input).digest("hex").slice(0, 16);
62
+ }
63
+
64
+ /**
65
+ * Fingerprint do finding: `arquivo + regra + âncora`. Sem a linha — permanece
66
+ * estável após edições que deslocam o trecho. Sem âncora, cai no `fallback`
67
+ * (ex.: `message`). Ocorrências com a MESMA âncora compartilham o fingerprint.
68
+ */
69
+ export function fingerprintOf(
70
+ file: string,
71
+ rule: string,
72
+ anchor: FindingAnchor | null,
73
+ fallback = "",
74
+ ): string {
75
+ const key = anchor ? anchorKey(anchor) : `fallback:${fallback}`;
76
+ return hashKey(`${file}\u0000${rule}\u0000${key}`);
77
+ }
78
+
79
+ /**
80
+ * Monta a âncora a partir do conteúdo: usa a `line` (1-based) quando válida;
81
+ * senão procura a linha que contém a `evidence`. Retorna null se não localizar
82
+ * (ex.: arquivo deletado/binário).
83
+ */
84
+ export function buildAnchor(
85
+ content: string,
86
+ line: number | undefined,
87
+ evidence?: string,
88
+ ): FindingAnchor | null {
89
+ const lines = normalizedLines(content);
90
+
91
+ let index = -1;
92
+ if (line !== undefined && line >= 1 && line <= lines.length) {
93
+ index = line - 1;
94
+ } else if (evidence) {
95
+ const needle = normalizeLine(evidence);
96
+ if (needle) index = lines.findIndex((l) => l.includes(needle));
97
+ }
98
+ if (index < 0) return null;
99
+
100
+ const anchor: FindingAnchor = { snippet: lines[index], line: index + 1 };
101
+ if (index - 1 >= 0) anchor.contextBefore = lines[index - 1];
102
+ if (index + 1 < lines.length) anchor.contextAfter = lines[index + 1];
103
+ return anchor;
104
+ }
105
+
106
+ /** `true` se a linha `i` casa a âncora (snippet + contexto). */
107
+ function matchesAt(lines: string[], i: number, anchor: FindingAnchor): boolean {
108
+ if (lines[i] !== anchor.snippet) return false;
109
+ if (anchor.contextBefore !== undefined && lines[i - 1] !== anchor.contextBefore) {
110
+ return false;
111
+ }
112
+ if (anchor.contextAfter !== undefined && lines[i + 1] !== anchor.contextAfter) {
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ /** Localiza a âncora no conteúdo atual (linha + nº de ocorrências). */
119
+ export function locateAnchor(
120
+ content: string,
121
+ anchor: FindingAnchor | null,
122
+ ): AnchorLocation {
123
+ if (!anchor) return { line: null, occurrences: 0 };
124
+ const lines = normalizedLines(content);
125
+ let occurrences = 0;
126
+ let first = -1;
127
+ for (let i = 0; i < lines.length; i++) {
128
+ if (!matchesAt(lines, i, anchor)) continue;
129
+ occurrences++;
130
+ if (first < 0) first = i;
131
+ }
132
+ return { line: first < 0 ? null : first + 1, occurrences };
133
+ }
134
+
135
+ export type ReanchorStatus = "ok" | "moved" | "stale";
136
+
137
+ /** Reancora um finding: recalcula a linha e classifica o deslocamento. */
138
+ export function reanchor(
139
+ content: string,
140
+ anchor: FindingAnchor | null,
141
+ previousLine: number | null,
142
+ ): { status: ReanchorStatus; line: number | null } {
143
+ const { line } = locateAnchor(content, anchor);
144
+ if (line === null) return { status: "stale", line: null };
145
+ if (previousLine !== null && line === previousLine) return { status: "ok", line };
146
+ return { status: "moved", line };
147
+ }