@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,87 @@
1
+ /**
2
+ * Descoberta e carregamento da configuração do projeto — biblioteca.
3
+ *
4
+ * O motor é agnóstico: a config do projeto (`HoundsmanConfig`) é resolvida em
5
+ * runtime a partir do cwd do consumidor — nunca importada estaticamente.
6
+ *
7
+ * Ordem de resolução:
8
+ * 1. `--config <path>` (explícito)
9
+ * 2. `houndsman.config.{ts,mts,js,mjs}`
10
+ * 3. `.houndsman/config.{ts,js}`
11
+ */
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import type { HoundsmanConfig } from "./config";
17
+
18
+ /** Nomes de arquivo procurados no cwd, em ordem de precedência. */
19
+ export const CONFIG_FILE_CANDIDATES: readonly string[] = [
20
+ "houndsman.config.ts",
21
+ "houndsman.config.mts",
22
+ "houndsman.config.js",
23
+ "houndsman.config.mjs",
24
+ ".houndsman/config.ts",
25
+ ".houndsman/config.js",
26
+ ];
27
+
28
+ export type LoadedConfig<TId extends string = string> = {
29
+ config: HoundsmanConfig<TId>;
30
+ /** Caminho absoluto do arquivo de config carregado. */
31
+ path: string;
32
+ };
33
+
34
+ /** Primeiro arquivo de config existente no diretório (ou `null`). */
35
+ export function resolveConfigPath(cwd: string = process.cwd()): string | null {
36
+ for (const name of CONFIG_FILE_CANDIDATES) {
37
+ const candidate = join(cwd, name);
38
+ if (existsSync(candidate)) return candidate;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ const MISSING_CONFIG_HINT =
44
+ "Nenhuma configuração do houndsman encontrada.\n" +
45
+ `Esperado: ${CONFIG_FILE_CANDIDATES.join(" | ")} na raiz do projeto.\n` +
46
+ "Rode `houndsman setup` para criar uma configuração inicial.";
47
+
48
+ /**
49
+ * Carrega o `HoundsmanConfig` do projeto. Lança se ausente ou inválido.
50
+ * `configPath` sobrepõe a descoberta automática.
51
+ */
52
+ export async function loadConfig<TId extends string = string>(
53
+ options: { cwd?: string; configPath?: string } = {},
54
+ ): Promise<LoadedConfig<TId>> {
55
+ const cwd = options.cwd ?? process.cwd();
56
+ const path = options.configPath
57
+ ? resolve(cwd, options.configPath)
58
+ : resolveConfigPath(cwd);
59
+
60
+ if (!path || !existsSync(path)) {
61
+ throw new Error(MISSING_CONFIG_HINT);
62
+ }
63
+
64
+ const module = (await import(pathToFileURL(path).href)) as {
65
+ default?: HoundsmanConfig<TId>;
66
+ houndsmanConfig?: HoundsmanConfig<TId>;
67
+ };
68
+ const config = module.default ?? module.houndsmanConfig;
69
+
70
+ if (!config) {
71
+ throw new Error(
72
+ `Configuração inválida em ${path}: exporte \`export default houndsmanConfig\`.`,
73
+ );
74
+ }
75
+ if (!config.watchdogs || typeof config.watchdogs !== "object") {
76
+ throw new Error(`Configuração inválida em ${path}: campo 'watchdogs' ausente.`);
77
+ }
78
+ // Deriva `watchdogIds` de quem não passou por `defineHoundsmanConfig` (ex.: objeto
79
+ // literal) — mantém o motor robusto a configs escritas à mão.
80
+ if (!Array.isArray(config.watchdogIds)) {
81
+ (config as unknown as { watchdogIds: string[] }).watchdogIds = Object.keys(
82
+ config.watchdogs,
83
+ );
84
+ }
85
+
86
+ return { config, path };
87
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Contrato de configuração do motor de revisão (houndsman) — biblioteca.
3
+ *
4
+ * O motor (`core/` + `cli/`) é agnóstico ao projeto: consome um `HoundsmanConfig`
5
+ * por injeção explícita. O projeto fornece a config em `houndsman.config.ts`
6
+ * (camada `houndsman/`) — único ponto de troca ao portar para outro repositório.
7
+ */
8
+
9
+ import { ARTIFACT_DIR } from "./paths";
10
+ import type { WatchdogDefinition } from "./types";
11
+
12
+ /** Documentos sempre incluídos nos briefs (default). */
13
+ export const DEFAULT_ALWAYS_INCLUDE_DOCS: readonly string[] = ["AGENTS.md"];
14
+
15
+ /**
16
+ * Teto default de arquivos por brief. Um PR grande (milhares de linhas) pode
17
+ * concentrar centenas de arquivos em um único domínio; o teto mantém o contexto
18
+ * do subagente limitado — o excedente vira "fora do orçamento" (cobertura
19
+ * honesta), nunca some silenciosamente.
20
+ */
21
+ export const DEFAULT_MAX_FILES_PER_BRIEF = 40;
22
+
23
+ /** Concorrência default das chamadas simultâneas ao Jev. */
24
+ export const DEFAULT_JEV_CONCURRENCY = 5;
25
+
26
+ /** Limites de triage para PRs grandes. */
27
+ export type HoundsmanBudgets = {
28
+ /** Máx. de arquivos por brief (default: 40). Excedente vai para "fora do orçamento". */
29
+ maxFilesPerBrief?: number;
30
+ };
31
+
32
+ /** Concorrência das fases paralelas internas. */
33
+ export type HoundsmanConcurrency = {
34
+ /** Chamadas Jev simultâneas (default: 5). */
35
+ jev?: number;
36
+ };
37
+
38
+ /**
39
+ * Configuração completa do motor de revisão para um projeto.
40
+ *
41
+ * `TId` é o union de ids de watchdog do projeto (ex.: `WatchdogId`).
42
+ */
43
+ export type HoundsmanConfig<TId extends string = string> = {
44
+ /** Nome legível do projeto (usado em prompts e docs). */
45
+ projectName: string;
46
+ /** Diretório dos artefatos (default: `ARTIFACT_DIR` da camada). */
47
+ artifactDir?: string;
48
+ /**
49
+ * Ids dos watchdogs — **derivados** das chaves de `watchdogs` por
50
+ * `defineHoundsmanConfig` (não informar manualmente).
51
+ */
52
+ watchdogIds: readonly TId[];
53
+ /** Definições dos watchdogs do projeto (fonte dos ids). */
54
+ watchdogs: Record<TId, WatchdogDefinition>;
55
+ /** Arquivos que nunca entram na revisão. */
56
+ exclusions: readonly RegExp[];
57
+ /** Mapeamentos path → documentação relevante. */
58
+ docMappings: readonly (readonly [RegExp, readonly string[]])[];
59
+ /** Docs sempre incluídos nos briefs (default: `DEFAULT_ALWAYS_INCLUDE_DOCS`). */
60
+ alwaysIncludeDocs?: readonly string[];
61
+ /** Limites de triage para PRs grandes (default: `DEFAULT_MAX_FILES_PER_BRIEF`). */
62
+ budgets?: HoundsmanBudgets;
63
+ /** Concorrência das fases paralelas internas. */
64
+ concurrency?: HoundsmanConcurrency;
65
+ };
66
+
67
+ /** Entrada de `defineHoundsmanConfig` — `watchdogIds` é derivado, não informado. */
68
+ export type HoundsmanConfigInput<TId extends string = string> = Omit<
69
+ HoundsmanConfig<TId>,
70
+ "watchdogIds"
71
+ >;
72
+
73
+ /**
74
+ * Helper de autoria: **deriva `watchdogIds`** das chaves de `watchdogs`
75
+ * (fonte única) e preserva a inferência de `TId`.
76
+ */
77
+ export function defineHoundsmanConfig<TId extends string>(
78
+ input: HoundsmanConfigInput<TId>,
79
+ ): HoundsmanConfig<TId> {
80
+ return { ...input, watchdogIds: Object.keys(input.watchdogs) as TId[] };
81
+ }
82
+
83
+ /** Diretório dos artefatos — o valor do projeto ou o default da camada. */
84
+ export function resolveArtifactDir(config: HoundsmanConfig): string {
85
+ return config.artifactDir ?? ARTIFACT_DIR;
86
+ }
87
+
88
+ /**
89
+ * Teto de arquivos por brief. Valor ausente/não-positivo cai no default —
90
+ * mantém o contexto do subagente limitado em PRs grandes.
91
+ */
92
+ export function resolveMaxFilesPerBrief(config: HoundsmanConfig): number {
93
+ const value = config.budgets?.maxFilesPerBrief;
94
+ return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
95
+ }
96
+
97
+ /** Concorrência das chamadas Jev (valor ausente/não-positivo cai no default). */
98
+ export function resolveJevConcurrency(config: HoundsmanConfig): number {
99
+ const value = config.concurrency?.jev;
100
+ return value !== undefined && value > 0 ? value : DEFAULT_JEV_CONCURRENCY;
101
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Coleta do diff em um único passo (biblioteca).
3
+ *
4
+ * Roda `git diff base...head` **uma vez** (patch) e `--numstat` **uma vez**
5
+ * (churn), fatiando o patch por arquivo em memória. Substitui os N spawns de
6
+ * `git diff -- <arquivo>` (antes feitos por arquivo, no `jev-detector`): o custo
7
+ * deixa de crescer com o número de arquivos da PR.
8
+ *
9
+ * Usado por `orchestrator.prepare` (churn para a classificação + patch para o
10
+ * Jev) e por `classifyRange` (fallback standalone).
11
+ */
12
+
13
+ import type { ChurnMap, FileChurn } from "./types";
14
+
15
+ export type DiffSource = {
16
+ /** path → patch do arquivo (header + hunks), truncado por `maxPerFileChars`. */
17
+ byFile: Map<string, string>;
18
+ /** path (novo) → linhas adicionadas/removidas. */
19
+ churn: Map<string, FileChurn>;
20
+ };
21
+
22
+ export type CollectDiffOptions = {
23
+ /** Teto de caracteres por arquivo no patch (default: 8_000). */
24
+ maxPerFileChars?: number;
25
+ };
26
+
27
+ const DEFAULT_MAX_PER_FILE_CHARS = 8_000;
28
+
29
+ /** Executa `git` com `core.quotepath=false` (paths literais) e devolve o stdout. */
30
+ function runGit(args: string[]): string {
31
+ const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ if (result.exitCode !== 0) {
36
+ const stderr = new TextDecoder().decode(result.stderr).trim();
37
+ throw new Error(`git ${args.join(" ")} falhou: ${stderr}`);
38
+ }
39
+ return new TextDecoder().decode(result.stdout);
40
+ }
41
+
42
+ /** Novo caminho do arquivo a partir do cabeçalho de um segmento do patch. */
43
+ function extractPath(segment: string): string | undefined {
44
+ // Só o cabeçalho (antes do primeiro hunk) pode conter as linhas `---`/`+++` —
45
+ // assim uma linha de conteúdo como `+++ b/x` não é confundida com o header.
46
+ const hunkAt = segment.indexOf("\n@@");
47
+ const header = hunkAt >= 0 ? segment.slice(0, hunkAt) : segment;
48
+ const lines = header.split("\n");
49
+
50
+ for (const line of lines) {
51
+ if (line.startsWith("+++ b/")) return line.slice(6);
52
+ }
53
+ for (const line of lines) {
54
+ // Deletados têm `+++ /dev/null`; o caminho antigo é a melhor referência.
55
+ if (line.startsWith("--- a/")) return line.slice(6);
56
+ }
57
+
58
+ // Fallback (ex.: binário, sem `---`/`+++`): `a/<old> b/<new>`.
59
+ const firstLine = lines[0] ?? "";
60
+ const marker = firstLine.lastIndexOf(" b/");
61
+ return marker >= 0 ? firstLine.slice(marker + 3) : undefined;
62
+ }
63
+
64
+ /** Fatia um patch unificado por arquivo (`diff --git`). */
65
+ export function parseDiffIndex(
66
+ patch: string,
67
+ maxPerFileChars = Number.POSITIVE_INFINITY,
68
+ ): Map<string, string> {
69
+ const map = new Map<string, string>();
70
+ for (const segment of patch.split(/^diff --git /m)) {
71
+ if (!segment.trim()) continue;
72
+ const path = extractPath(segment);
73
+ if (!path) continue;
74
+ const text = `diff --git ${segment}`;
75
+ map.set(
76
+ path,
77
+ text.length > maxPerFileChars ? text.slice(0, maxPerFileChars) : text,
78
+ );
79
+ }
80
+ return map;
81
+ }
82
+
83
+ /** Contagem do `--numstat` (`-` em binários) → número. */
84
+ function toCount(value: string): number {
85
+ const parsed = Number.parseInt(value, 10);
86
+ return Number.isNaN(parsed) ? 0 : parsed;
87
+ }
88
+
89
+ /** Fatia `git diff --numstat -z` em path → churn (trata renames/cópias/bins). */
90
+ export function parseNumstat(raw: string): Map<string, FileChurn> {
91
+ const map = new Map<string, FileChurn>();
92
+ const tokens = raw.split("\0");
93
+
94
+ for (let i = 0; i < tokens.length; i++) {
95
+ const token = tokens[i];
96
+ if (!token) continue;
97
+ const fields = token.split("\t");
98
+ if (fields.length < 3) continue;
99
+
100
+ let path = fields[2];
101
+ if (path === "") {
102
+ // Renomeado/copiado: os dois caminhos seguem como tokens próprios.
103
+ path = tokens[i + 2] ?? "";
104
+ i += 2;
105
+ }
106
+ if (!path) continue;
107
+
108
+ map.set(path, { additions: toCount(fields[0]), deletions: toCount(fields[1]) });
109
+ }
110
+ return map;
111
+ }
112
+
113
+ /** Churn por arquivo (`git diff --numstat -z`) — 1 spawn. */
114
+ export function collectChurn(base: string, head: string): Map<string, FileChurn> {
115
+ return parseNumstat(runGit(["diff", "--numstat", "-z", `${base}...${head}`]));
116
+ }
117
+
118
+ /**
119
+ * Patch + churn em uma passada: 2 spawns fixos, independentes do nº de arquivos.
120
+ */
121
+ export function collectDiff(
122
+ base: string,
123
+ head: string,
124
+ options: CollectDiffOptions = {},
125
+ ): DiffSource {
126
+ const range = `${base}...${head}`;
127
+ const patch = runGit(["diff", range]);
128
+ return {
129
+ byFile: parseDiffIndex(
130
+ patch,
131
+ options.maxPerFileChars ?? DEFAULT_MAX_PER_FILE_CHARS,
132
+ ),
133
+ churn: collectChurn(base, head),
134
+ };
135
+ }
136
+
137
+ /** Converte o Map de churn em Record (para o `classification.json`). */
138
+ export function churnToRecord(churn: Map<string, FileChurn>): ChurnMap {
139
+ return Object.fromEntries(churn);
140
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Descobre documentação relevante para um conjunto de arquivos alterados
3
+ * (biblioteca). Entrypoint: `cli/review.ts`.
4
+ *
5
+ * Os mapeamentos (`docMappings`) e os docs sempre incluídos vivem no projeto
6
+ * (`project/doc-mappings.ts`) e chegam via `HoundsmanConfig`.
7
+ */
8
+
9
+ import { DEFAULT_ALWAYS_INCLUDE_DOCS, type HoundsmanConfig } from "./config";
10
+
11
+ /** Descobre docs relevantes com base nos paths + docs sempre incluídos. */
12
+ export function discoverDocs(filePaths: string[], config: HoundsmanConfig): string[] {
13
+ const docs = new Set<string>();
14
+
15
+ for (const doc of config.alwaysIncludeDocs ?? DEFAULT_ALWAYS_INCLUDE_DOCS) {
16
+ docs.add(doc);
17
+ }
18
+
19
+ for (const filePath of filePaths) {
20
+ const normalized = filePath.trim().toLowerCase();
21
+ if (!normalized) continue;
22
+
23
+ for (const [pattern, docPaths] of config.docMappings) {
24
+ if (pattern.test(normalized)) {
25
+ for (const docPath of docPaths) {
26
+ docs.add(docPath);
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ return [...docs].sort();
33
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Classificação de arquivos alterados por domínio de review (biblioteca).
3
+ *
4
+ * Cada watchdog declara os arquivos que cobre (`classify`); exclusões chegam via
5
+ * `HoundsmanConfig`. Use `classifyRange(base, head, config)`.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import type { HoundsmanConfig } from "./config";
10
+ import { churnToRecord, collectChurn, type DiffSource } from "./diff-source";
11
+ import type { ChurnMap, ClassifyRule, WatchdogDefinition } from "./types";
12
+
13
+ type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
14
+
15
+ type FileEntry = {
16
+ path: string;
17
+ status: FileStatus;
18
+ oldPath?: string;
19
+ };
20
+
21
+ export type ClassificationResult = {
22
+ /** watchdogId → file paths (apenas arquivos revisáveis) */
23
+ watchdogs: Record<string, string[]>;
24
+ /** path → metadados (status, oldPath) */
25
+ files: Record<string, { status: FileStatus; oldPath?: string }>;
26
+ /** path → linhas adicionadas/removidas (churn do diff) */
27
+ churn: ChurnMap;
28
+ /** Arquivos não classificados (revisar manualmente) */
29
+ unclassified: string[];
30
+ /** Arquivos excluídos da revisão (gerados, locks, builds) */
31
+ excluded: string[];
32
+ /** Arquivos deletados — candidatos a breaking change, sem conteúdo para ler */
33
+ deleted: string[];
34
+ /** Estatísticas por watchdog */
35
+ stats: Record<string, number>;
36
+ excludedCount: number;
37
+ base?: string;
38
+ head?: string;
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Matchers genéricos (reutilizados pelo projeto)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Tipo de classificação assumido quando `type` é omitido. */
46
+ const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
47
+
48
+ /** `true` se há entradas de conteúdo (as únicas que exigem ler o arquivo). */
49
+ function hasContentRules<TId extends string>(
50
+ watchdogs: Record<TId, WatchdogDefinition>,
51
+ watchdogIds: readonly TId[],
52
+ ): boolean {
53
+ return watchdogIds.some((id) =>
54
+ watchdogs[id]?.classify?.some((rule) => rule.type === "content"),
55
+ );
56
+ }
57
+
58
+ /** Lê o arquivo em UTF-8; `undefined` se indisponível (binário, ausente). */
59
+ function readFileContent(filePath: string): string | undefined {
60
+ try {
61
+ return readFileSync(filePath, "utf8");
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /** `true` se o arquivo casa a entrada de classificação. */
68
+ function matchesClassifyRule(
69
+ filePath: string,
70
+ rule: ClassifyRule,
71
+ content: string | undefined,
72
+ ): boolean {
73
+ if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
74
+ return content !== undefined && new RegExp(rule.value).test(content);
75
+ }
76
+ return new RegExp(rule.value).test(filePath);
77
+ }
78
+
79
+ /** Watchdogs que declaram o arquivo (união dos `classify`). */
80
+ export function matchWatchdogs<TId extends string>(
81
+ filePath: string,
82
+ watchdogs: Record<TId, WatchdogDefinition>,
83
+ watchdogIds: readonly TId[],
84
+ content?: string,
85
+ ): TId[] {
86
+ const ids: TId[] = [];
87
+ for (const id of watchdogIds) {
88
+ if (
89
+ watchdogs[id]?.classify?.some((rule) =>
90
+ matchesClassifyRule(filePath, rule, content),
91
+ )
92
+ ) {
93
+ ids.push(id);
94
+ }
95
+ }
96
+ return ids;
97
+ }
98
+
99
+ /** `true` se o arquivo casa algum padrão de exclusão. */
100
+ export function isExcludedPath(filePath: string, patterns: readonly RegExp[]): boolean {
101
+ return patterns.some((pattern) => pattern.test(filePath));
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Execução do git diff
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function runGitDiff(base: string, head: string): string {
109
+ const result = Bun.spawnSync(["git", "diff", `${base}...${head}`, "--name-status"], {
110
+ stdout: "pipe",
111
+ stderr: "pipe",
112
+ });
113
+ if (result.exitCode !== 0) {
114
+ const stderr = new TextDecoder().decode(result.stderr).trim();
115
+ throw new Error(`git diff falhou (base='${base}', head='${head}'): ${stderr}`);
116
+ }
117
+ return new TextDecoder().decode(result.stdout);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Parsing de `git diff --name-status`
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function mapStatus(code: string): FileStatus {
125
+ const letter = code[0]?.toUpperCase();
126
+ switch (letter) {
127
+ case "A":
128
+ return "added";
129
+ case "M":
130
+ return "modified";
131
+ case "D":
132
+ return "deleted";
133
+ case "R":
134
+ return "renamed";
135
+ case "C":
136
+ return "copied";
137
+ default:
138
+ return "unknown";
139
+ }
140
+ }
141
+
142
+ export function parseEntries(raw: string): FileEntry[] {
143
+ const entries: FileEntry[] = [];
144
+ for (const line of raw.split("\n")) {
145
+ const trimmed = line.trim();
146
+ if (!trimmed) continue;
147
+
148
+ const fields = trimmed.split("\t");
149
+ if (fields.length === 1) {
150
+ // Sem status — assume modificado
151
+ entries.push({ path: fields[0].trim(), status: "modified" });
152
+ continue;
153
+ }
154
+
155
+ const status = mapStatus(fields[0].trim());
156
+ if (fields.length >= 3 && (status === "renamed" || status === "copied")) {
157
+ // R100\told\tnew
158
+ entries.push({ path: fields[2].trim(), oldPath: fields[1].trim(), status });
159
+ } else {
160
+ entries.push({ path: fields[1].trim(), status });
161
+ }
162
+ }
163
+ return entries;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Classificação
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export function classifyEntries<TId extends string>(
171
+ entries: FileEntry[],
172
+ config: HoundsmanConfig<TId>,
173
+ churn: ChurnMap = {},
174
+ ): ClassificationResult {
175
+ const watchdogFiles: Record<string, string[]> = {};
176
+ const files: ClassificationResult["files"] = {};
177
+ const unclassified: string[] = [];
178
+ const excluded: string[] = [];
179
+ const deleted: string[] = [];
180
+ // Leitura de conteúdo só é habilitada se algum watchdog declarar regra `content`.
181
+ const needsContent = hasContentRules(config.watchdogs, config.watchdogIds);
182
+
183
+ for (const entry of entries) {
184
+ const { path, status, oldPath } = entry;
185
+ files[path] = oldPath ? { status, oldPath } : { status };
186
+
187
+ if (isExcludedPath(path, config.exclusions)) {
188
+ excluded.push(path);
189
+ continue;
190
+ }
191
+
192
+ if (status === "deleted") {
193
+ // Sem conteúdo para revisar — exposto separadamente p/ checagem de breaking change
194
+ deleted.push(path);
195
+ continue;
196
+ }
197
+
198
+ const content = needsContent ? readFileContent(path) : undefined;
199
+ const ids: string[] = matchWatchdogs(
200
+ path,
201
+ config.watchdogs,
202
+ config.watchdogIds,
203
+ content,
204
+ );
205
+
206
+ if (ids.length === 0) {
207
+ unclassified.push(path);
208
+ continue;
209
+ }
210
+
211
+ for (const id of ids) {
212
+ watchdogFiles[id] ??= [];
213
+ watchdogFiles[id].push(path);
214
+ }
215
+ }
216
+
217
+ const watchdogs: Record<string, string[]> = {};
218
+ const stats: Record<string, number> = {};
219
+ for (const id of config.watchdogIds) {
220
+ const list = [...new Set(watchdogFiles[id] ?? [])];
221
+ if (list.length > 0) {
222
+ watchdogs[id] = list;
223
+ stats[id] = list.length;
224
+ }
225
+ }
226
+
227
+ return {
228
+ watchdogs,
229
+ files,
230
+ churn,
231
+ unclassified,
232
+ excluded,
233
+ deleted,
234
+ stats,
235
+ excludedCount: excluded.length,
236
+ };
237
+ }
238
+
239
+ /**
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.
244
+ */
245
+ export function classifyRange<TId extends string>(
246
+ base: string,
247
+ head: string,
248
+ config: HoundsmanConfig<TId>,
249
+ diff?: DiffSource,
250
+ ): ClassificationResult {
251
+ const entries = parseEntries(runGitDiff(base, head));
252
+ const churn = diff?.churn ?? collectChurn(base, head);
253
+ const result = classifyEntries(entries, config, churnToRecord(churn));
254
+ result.base = base;
255
+ result.head = head;
256
+ return result;
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
+ }