@payconductor/houndsman 0.2.0 → 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 (68) 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 +58 -266
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +34 -144
  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 +170 -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 +4 -33
  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/index.ts +64 -234
  64. package/src/server/run-repository.ts +291 -0
  65. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  66. package/src/tests/baseline.test.ts +513 -493
  67. package/src/tests/domain.test.ts +328 -0
  68. package/src/tests/infrastructure.test.ts +210 -0
@@ -13,18 +13,18 @@ export type UpdateOptions = {
13
13
  args: string[];
14
14
  };
15
15
 
16
- /** Sincroniza os arquivos auto-gerados da camada de projeto. */
17
- export async function runUpdateCommand(options: UpdateOptions): Promise<void> {
16
+ /** Sincroniza os arquivos auto-gerados da camada de projeto. Devolve mensagens. */
17
+ export async function runUpdateCommand(options: UpdateOptions): Promise<string[]> {
18
18
  const ctx = createScaffoldContext(options.cwd, options.args, false);
19
-
20
- console.log(`→ Sincronizando arquivos auto-gerados em ${ctx.cwd}`);
19
+ const messages = [`→ Sincronizando arquivos auto-gerados em ${ctx.cwd}`];
21
20
 
22
21
  syncGeneratedFiles(ctx);
23
22
 
24
- for (const path of ctx.generated) console.log(` ~ ${path} (auto)`);
23
+ for (const path of ctx.generated) messages.push(` ~ ${path} (auto)`);
25
24
 
26
- console.log("\nArquivos do usuário não foram tocados:");
27
- console.log(
25
+ messages.push(
26
+ "\nArquivos do usuário não foram tocados:",
28
27
  " .houndsman/watchdogs/ · exclusions.ts · doc-mappings.ts · houndsman.config.ts",
29
28
  );
29
+ return messages;
30
30
  }
@@ -6,12 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { HoundsmanConfig } from "../config";
9
- import {
10
- type Rule,
11
- SEVERITY_EMOJI,
12
- severityRank,
13
- type WatchdogDefinition,
14
- } from "../types";
9
+ import { WatchdogPresenter } from "../renderers/watchdog-presenter";
15
10
  import { findWatchdog, getWatchdogContext } from "../watchdog-registry";
16
11
 
17
12
  export type WatchdogContextOptions = {
@@ -21,7 +16,7 @@ export type WatchdogContextOptions = {
21
16
  json?: boolean;
22
17
  };
23
18
 
24
- type WatchdogContext = ReturnType<typeof getWatchdogContext>;
19
+ const presenter = new WatchdogPresenter();
25
20
 
26
21
  /** Renderiza o contexto de um watchdog (markdown ou JSON). `null` se o id não existe. */
27
22
  export function renderWatchdogContext(
@@ -29,77 +24,10 @@ export function renderWatchdogContext(
29
24
  id: string,
30
25
  opts: WatchdogContextOptions = {},
31
26
  ): string | null {
32
- const def = findWatchdog(config, id);
33
- if (!def) return null;
34
- const ctx = getWatchdogContext(config, def.id);
35
- const withRules = opts.withRules ?? false;
36
- return opts.json
37
- ? serializeWatchdogContext(ctx, def, withRules)
38
- : renderWatchdogMarkdown(ctx, def, withRules);
39
- }
40
-
41
- /** Payload serializável (regras incluídas apenas com `withRules`). */
42
- function serializeWatchdogContext(
43
- ctx: WatchdogContext,
44
- def: WatchdogDefinition,
45
- withRules: boolean,
46
- ): string {
47
- const payload = withRules ? { ...ctx, rules: def.rules } : { ...ctx };
48
- return JSON.stringify(payload, null, 2);
49
- }
50
-
51
- /** Lista de itens em bloco `## <título>` (vazio se não houver itens). */
52
- function section(title: string, items: readonly string[] | undefined): string[] {
53
- if (!items || items.length === 0) return [];
54
- return [`## ${title}`, "", ...items.map((item) => `- ${item}`), ""];
55
- }
56
-
57
- /** Regras com emoji de severidade em bloco `## <título>`. */
58
- function ruleSection(title: string, rules: readonly Rule[]): string[] {
59
- return [
60
- `## ${title}`,
61
- "",
62
- ...rules.map(
63
- (rule) =>
64
- `- ${SEVERITY_EMOJI[rule.severity]} **${rule.severity}** — ${rule.text}`,
65
- ),
66
- "",
67
- ];
68
- }
69
-
70
- /** Render markdown legível do contexto do watchdog. */
71
- function renderWatchdogMarkdown(
72
- ctx: WatchdogContext,
73
- def: WatchdogDefinition,
74
- withRules: boolean,
75
- ): string {
76
- const lines: string[] = [
77
- `# ${ctx.name} (\`${ctx.id}\`)`,
78
- "",
79
- `**Descrição:** ${ctx.description}`,
80
- `**Escopo:** ${ctx.scope}`,
81
- "",
82
- ];
83
-
84
- lines.push(...section("Prioridade de leitura", ctx.reviewFocus));
85
- lines.push(...section("Não carregar", ctx.exclude));
86
- lines.push(
87
- ...section(
88
- "Docs de contexto",
89
- ctx.contextFiles.map((doc) => `\`${doc}\``),
90
- ),
91
- );
92
-
93
- if (withRules) {
94
- lines.push(
95
- ...ruleSection(
96
- "Regras",
97
- [...def.rules].sort(
98
- (a, b) => severityRank(a.severity) - severityRank(b.severity),
99
- ),
100
- ),
101
- );
102
- }
103
-
104
- return lines.join("\n").trimEnd();
27
+ const definition = findWatchdog(config, id);
28
+ if (!definition) return null;
29
+ return presenter.render(getWatchdogContext(config, definition.id), definition, {
30
+ withRules: opts.withRules ?? false,
31
+ json: opts.json ?? false,
32
+ });
105
33
  }
@@ -6,22 +6,14 @@
6
6
  * (camada `houndsman/`) — único ponto de troca ao portar para outro repositório.
7
7
  */
8
8
 
9
- import { ARTIFACT_DIR } from "./paths";
9
+ import { ProjectConfig } from "./project-config";
10
10
  import type { WatchdogDefinition } from "./types";
11
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;
12
+ export {
13
+ DEFAULT_ALWAYS_INCLUDE_DOCS,
14
+ DEFAULT_JEV_CONCURRENCY,
15
+ DEFAULT_MAX_FILES_PER_BRIEF,
16
+ } from "./defaults";
25
17
 
26
18
  /** Limites de triage para PRs grandes. */
27
19
  export type HoundsmanBudgets = {
@@ -82,7 +74,7 @@ export function defineHoundsmanConfig<TId extends string>(
82
74
 
83
75
  /** Diretório dos artefatos — o valor do projeto ou o default da camada. */
84
76
  export function resolveArtifactDir(config: HoundsmanConfig): string {
85
- return config.artifactDir ?? ARTIFACT_DIR;
77
+ return new ProjectConfig(config).artifactDir;
86
78
  }
87
79
 
88
80
  /**
@@ -90,12 +82,10 @@ export function resolveArtifactDir(config: HoundsmanConfig): string {
90
82
  * mantém o contexto do subagente limitado em PRs grandes.
91
83
  */
92
84
  export function resolveMaxFilesPerBrief(config: HoundsmanConfig): number {
93
- const value = config.budgets?.maxFilesPerBrief;
94
- return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
85
+ return new ProjectConfig(config).maxFilesPerBrief;
95
86
  }
96
87
 
97
88
  /** Concorrência das chamadas Jev (valor ausente/não-positivo cai no default). */
98
89
  export function resolveJevConcurrency(config: HoundsmanConfig): number {
99
- const value = config.concurrency?.jev;
100
- return value !== undefined && value > 0 ? value : DEFAULT_JEV_CONCURRENCY;
90
+ return new ProjectConfig(config).jevConcurrency;
101
91
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Defaults do motor (biblioteca) — valores canônicos compartilhados.
3
+ *
4
+ * Vivem fora de `config.ts` para que `ProjectConfig` possa usá-los sem criar
5
+ * ciclo de importação com os adaptadores públicos.
6
+ */
7
+
8
+ /** Documentos sempre incluídos nos briefs (default). */
9
+ export const DEFAULT_ALWAYS_INCLUDE_DOCS: readonly string[] = ["AGENTS.md"];
10
+
11
+ /**
12
+ * Teto default de arquivos por brief. Um PR grande (milhares de linhas) pode
13
+ * concentrar centenas de arquivos em um único domínio; o teto mantém o contexto
14
+ * do subagente limitado — o excedente vira "fora do orçamento" (cobertura
15
+ * honesta), nunca some silenciosamente.
16
+ */
17
+ export const DEFAULT_MAX_FILES_PER_BRIEF = 40;
18
+
19
+ /** Concorrência default das chamadas simultâneas ao Jev. */
20
+ export const DEFAULT_JEV_CONCURRENCY = 5;
@@ -9,6 +9,7 @@
9
9
  * Jev) e por `classifyRange` (fallback standalone).
10
10
  */
11
11
 
12
+ import { runGit } from "./git-runner";
12
13
  import type { ChurnMap, FileChurn } from "./types";
13
14
 
14
15
  export type DiffSource = {
@@ -26,19 +27,6 @@ export type CollectDiffOptions = {
26
27
  /** Teto default de caracteres por arquivo no patch (compartilhado com o git-revision). */
27
28
  export const DEFAULT_MAX_PER_FILE_CHARS = 8_000;
28
29
 
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
30
  /** Novo caminho do arquivo a partir do cabeçalho de um segmento do patch. */
43
31
  function extractPath(segment: string): string | undefined {
44
32
  // Só o cabeçalho (antes do primeiro hunk) pode conter as linhas `---`/`+++` —
@@ -6,28 +6,38 @@
6
6
  * (`project/doc-mappings.ts`) e chegam via `HoundsmanConfig`.
7
7
  */
8
8
 
9
- import { DEFAULT_ALWAYS_INCLUDE_DOCS, type HoundsmanConfig } from "./config";
9
+ import type { HoundsmanConfig } from "./config";
10
+ import { ProjectConfig } from "./project-config";
10
11
 
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>();
12
+ /** Descobre documentação relevante para um conjunto de arquivos alterados. */
13
+ export class DocDiscoverer {
14
+ private readonly alwaysInclude: readonly string[];
15
+ private readonly mappings: readonly (readonly [RegExp, readonly string[]])[];
14
16
 
15
- for (const doc of config.alwaysIncludeDocs ?? DEFAULT_ALWAYS_INCLUDE_DOCS) {
16
- docs.add(doc);
17
+ constructor(config: HoundsmanConfig) {
18
+ const project = new ProjectConfig(config);
19
+ this.alwaysInclude = project.alwaysIncludeDocs;
20
+ this.mappings = project.docMappings;
17
21
  }
18
22
 
19
- for (const filePath of filePaths) {
20
- const normalized = filePath.trim().toLowerCase();
21
- if (!normalized) continue;
23
+ discover(filePaths: readonly string[]): string[] {
24
+ const docs = new Set<string>(this.alwaysInclude);
22
25
 
23
- for (const [pattern, docPaths] of config.docMappings) {
24
- if (pattern.test(normalized)) {
25
- for (const docPath of docPaths) {
26
- docs.add(docPath);
27
- }
26
+ for (const filePath of filePaths) {
27
+ const normalized = filePath.trim().toLowerCase();
28
+ if (!normalized) continue;
29
+
30
+ for (const [pattern, docPaths] of this.mappings) {
31
+ if (!pattern.test(normalized)) continue;
32
+ for (const docPath of docPaths) docs.add(docPath);
28
33
  }
29
34
  }
35
+
36
+ return [...docs].sort();
30
37
  }
38
+ }
31
39
 
32
- return [...docs].sort();
40
+ /** Descobre docs relevantes com base nos paths + docs sempre incluídos. */
41
+ export function discoverDocs(filePaths: string[], config: HoundsmanConfig): string[] {
42
+ return new DocDiscoverer(config).discover(filePaths);
33
43
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Orçamento por brief (domínio) — funções puras de triagem de PRs grandes.
3
+ *
4
+ * Separado de `file-classifier` para que `Classification` possa aplicar o
5
+ * orçamento sem depender do serviço de classificação.
6
+ */
7
+
8
+ import type { ChurnMap } from "../types";
9
+
10
+ /** Churn total (adições + remoções) de um arquivo; 0 quando ausente. */
11
+ export function churnOf(filePath: string, churn: ChurnMap | undefined): number {
12
+ const entry = churn?.[filePath];
13
+ return entry ? entry.additions + entry.deletions : 0;
14
+ }
15
+
16
+ /** Ordena os arquivos por risco (churn desc; empate pelo caminho). */
17
+ export function sortByRisk(files: readonly string[], churn?: ChurnMap): string[] {
18
+ return [...files].sort((a, b) => {
19
+ const delta = churnOf(b, churn) - churnOf(a, churn);
20
+ return delta !== 0 ? delta : a.localeCompare(b);
21
+ });
22
+ }
23
+
24
+ /** Resultado da aplicação do orçamento: o que revisar e o que ficou de fora. */
25
+ export type BudgetSelection = { selected: string[]; overflow: string[] };
26
+
27
+ /**
28
+ * Aplica o orçamento por brief: mantém os `maxFiles` de maior risco e devolve o
29
+ * restante em `overflow` (nunca descarta silenciosamente). `maxFiles` não-positivo
30
+ * ou não-finito desativa o orçamento.
31
+ */
32
+ export function selectByBudget(
33
+ files: readonly string[],
34
+ churn: ChurnMap | undefined,
35
+ maxFiles: number,
36
+ ): BudgetSelection {
37
+ const ordered = sortByRisk(files, churn);
38
+ if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
39
+ return { selected: ordered, overflow: [] };
40
+ }
41
+ return { selected: ordered.slice(0, maxFiles), overflow: ordered.slice(maxFiles) };
42
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Classificação (domínio) — arquivos alterados por domínio de review.
3
+ *
4
+ * Envolve o `ClassificationResult` produzido pelo classificador e concentra as
5
+ * consultas usadas pelo orquestrador (arquivos por watchdog, orçamento, churn).
6
+ */
7
+
8
+ import type { ClassificationResult } from "../file-classifier";
9
+ import type { ChurnMap } from "../types";
10
+ import { type BudgetSelection, selectByBudget } from "./budget";
11
+
12
+ /** Classificação de um diff por watchdog. */
13
+ export class Classification {
14
+ private readonly data: ClassificationResult;
15
+
16
+ constructor(data: ClassificationResult) {
17
+ this.data = data;
18
+ }
19
+
20
+ static from(data: ClassificationResult): Classification {
21
+ return new Classification(data);
22
+ }
23
+
24
+ get watchdogs(): Record<string, string[]> {
25
+ return this.data.watchdogs;
26
+ }
27
+
28
+ get files(): ClassificationResult["files"] {
29
+ return this.data.files;
30
+ }
31
+
32
+ get churn(): ChurnMap {
33
+ return this.data.churn;
34
+ }
35
+
36
+ get unclassified(): string[] {
37
+ return this.data.unclassified;
38
+ }
39
+
40
+ get excluded(): string[] {
41
+ return this.data.excluded;
42
+ }
43
+
44
+ get deleted(): string[] {
45
+ return this.data.deleted;
46
+ }
47
+
48
+ get stats(): Record<string, number> {
49
+ return this.data.stats;
50
+ }
51
+
52
+ get excludedCount(): number {
53
+ return this.data.excludedCount;
54
+ }
55
+
56
+ get base(): string | undefined {
57
+ return this.data.base;
58
+ }
59
+
60
+ get head(): string | undefined {
61
+ return this.data.head;
62
+ }
63
+
64
+ /** Ids de watchdog ativados (com ao menos um arquivo). */
65
+ get activeIds(): string[] {
66
+ return Object.keys(this.data.watchdogs);
67
+ }
68
+
69
+ /** Arquivos de um watchdog (vazio quando não ativado). */
70
+ watchdogsFor(id: string): string[] {
71
+ return this.data.watchdogs[id] ?? [];
72
+ }
73
+
74
+ /** Aplica o orçamento aos arquivos de um watchdog. */
75
+ budgetFor(id: string, maxFiles: number): BudgetSelection {
76
+ return selectByBudget(this.watchdogsFor(id), this.data.churn, maxFiles);
77
+ }
78
+
79
+ /** Totais de churn do diff. */
80
+ get churnTotals(): { additions: number; deletions: number } {
81
+ let additions = 0;
82
+ let deletions = 0;
83
+ for (const entry of Object.values(this.data.churn ?? {})) {
84
+ additions += entry.additions;
85
+ deletions += entry.deletions;
86
+ }
87
+ return { additions, deletions };
88
+ }
89
+
90
+ /** Payload serializável (mesmo shape do `classification.json`). */
91
+ toJSON(): ClassificationResult {
92
+ return this.data;
93
+ }
94
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Diff (domínio) — patch por arquivo + churn, com consultas coesas.
3
+ *
4
+ * Envolve o `DiffSource` coletado uma única vez pelo motor; não executa git.
5
+ */
6
+
7
+ import type { DiffSource } from "../diff-source";
8
+ import type { FileChurn } from "../types";
9
+
10
+ /** Diff coletado de um intervalo de commits. */
11
+ export class Diff {
12
+ private readonly source: DiffSource;
13
+
14
+ constructor(source: DiffSource) {
15
+ this.source = source;
16
+ }
17
+
18
+ /** Diff vazio (sem patch nem churn). */
19
+ static empty(): Diff {
20
+ return new Diff({ byFile: new Map(), churn: new Map() });
21
+ }
22
+
23
+ /** Todos os caminhos presentes no patch ou no churn. */
24
+ get paths(): string[] {
25
+ return [...new Set([...this.source.byFile.keys(), ...this.source.churn.keys()])];
26
+ }
27
+
28
+ /** Patch (header + hunks) de um arquivo, se houver. */
29
+ patchFor(path: string): string | undefined {
30
+ return this.source.byFile.get(path);
31
+ }
32
+
33
+ /** Churn de um arquivo; zero quando ausente. */
34
+ churnOf(path: string): FileChurn {
35
+ return this.source.churn.get(path) ?? { additions: 0, deletions: 0 };
36
+ }
37
+
38
+ /** Totais de churn do diff. */
39
+ get totals(): { additions: number; deletions: number } {
40
+ let additions = 0;
41
+ let deletions = 0;
42
+ for (const entry of this.source.churn.values()) {
43
+ additions += entry.additions;
44
+ deletions += entry.deletions;
45
+ }
46
+ return { additions, deletions };
47
+ }
48
+
49
+ /** `DiffSource` subjacente (para as funções puras que ainda o recebem). */
50
+ toSource(): DiffSource {
51
+ return this.source;
52
+ }
53
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Watchdog (domínio) — envolve a definição declarativa do projeto.
3
+ *
4
+ * A configuração continua sendo um objeto plano (`WatchdogDefinition`); esta
5
+ * classe concentra o comportamento (casar arquivo, ordenar regras, contexto e
6
+ * render do brief) sem alterar o contrato do consumidor.
7
+ */
8
+
9
+ import { renderBrief } from "../brief";
10
+ import {
11
+ type ClassifyRule,
12
+ type Rule,
13
+ severityRank,
14
+ type WatchdogDefinition,
15
+ type WatchdogRenderArgs,
16
+ } from "../types";
17
+
18
+ /** Tipo assumido quando `type` é omitido. */
19
+ const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
20
+
21
+ /** `true` se o arquivo casa a entrada de classificação. */
22
+ export function matchesClassifyRule(
23
+ filePath: string,
24
+ rule: ClassifyRule,
25
+ content: string | undefined,
26
+ ): boolean {
27
+ if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
28
+ return content !== undefined && new RegExp(rule.value).test(content);
29
+ }
30
+ return new RegExp(rule.value).test(filePath);
31
+ }
32
+
33
+ /** Contexto "essencial" do watchdog — SEM regras (docs/UI/inspeção). */
34
+ export type WatchdogContext = {
35
+ id: string;
36
+ name: string;
37
+ description: string;
38
+ scope: string;
39
+ reviewFocus?: readonly string[];
40
+ exclude?: readonly string[];
41
+ contextFiles: string[];
42
+ };
43
+
44
+ /** Comportamento de um watchdog do projeto. */
45
+ export class Watchdog {
46
+ readonly definition: WatchdogDefinition;
47
+
48
+ constructor(definition: WatchdogDefinition) {
49
+ this.definition = definition;
50
+ }
51
+
52
+ get id(): string {
53
+ return this.definition.id;
54
+ }
55
+
56
+ get name(): string {
57
+ return this.definition.name;
58
+ }
59
+
60
+ get description(): string {
61
+ return this.definition.description;
62
+ }
63
+
64
+ get scope(): string {
65
+ return this.definition.scope;
66
+ }
67
+
68
+ /** Regras de classificação declaradas (vazio = não casa nenhum arquivo). */
69
+ get classify(): readonly ClassifyRule[] {
70
+ return this.definition.classify ?? [];
71
+ }
72
+
73
+ /** `true` se o arquivo é deste domínio. */
74
+ matches(filePath: string, content?: string): boolean {
75
+ return this.classify.some((rule) => matchesClassifyRule(filePath, rule, content));
76
+ }
77
+
78
+ /** Regras ordenadas por severidade (mais grave primeiro). */
79
+ get rules(): readonly Rule[] {
80
+ return [...this.definition.rules].sort(
81
+ (a, b) => severityRank(a.severity) - severityRank(b.severity),
82
+ );
83
+ }
84
+
85
+ /** Metadados sem as regras. */
86
+ context(): WatchdogContext {
87
+ const { id, name, description, scope, reviewFocus, exclude, contextFiles } =
88
+ this.definition;
89
+ return { id, name, description, scope, reviewFocus, exclude, contextFiles };
90
+ }
91
+
92
+ /** Render do brief (hook custom ou default). */
93
+ renderBrief(args: WatchdogRenderArgs): string {
94
+ return renderBrief(this.definition, args);
95
+ }
96
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Carry-over do review incremental (engine) — findings de arquivos inalterados.
3
+ *
4
+ * Preserva o estado anterior sem re-revisar: o prepare carrega o carry-over e o
5
+ * finalize o mescla aos findings re-reportados.
6
+ */
7
+
8
+ import type { FindingsStateFile } from "../findings-state";
9
+ import type { MergedFinding } from "../report-builder";
10
+ import { severityRank } from "../types";
11
+
12
+ /** Chave de dedupe de finding: `(file, line, rule)`. */
13
+ function findingKey(finding: Pick<MergedFinding, "file" | "line" | "rule">): string {
14
+ return `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
15
+ }
16
+
17
+ /**
18
+ * Findings do review anterior em arquivos inalterados — carregados sem
19
+ * re-revisão (carry-over). Preserva o `foundAtSha` para o diff focado do verify.
20
+ */
21
+ export function buildCarryover(
22
+ previous: FindingsStateFile | null,
23
+ changedPaths: Set<string>,
24
+ ): MergedFinding[] {
25
+ if (!previous) return [];
26
+ return Object.values(previous.findings)
27
+ .filter((finding) => finding.present && !changedPaths.has(finding.file))
28
+ .map((finding) => ({
29
+ id: finding.id,
30
+ rule: finding.rule,
31
+ severity: finding.severity,
32
+ file: finding.file,
33
+ line: finding.line ?? undefined,
34
+ message: finding.message,
35
+ action: finding.action,
36
+ evidence: finding.evidence,
37
+ foundAtSha: finding.foundAtSha,
38
+ watchdogs: [...finding.watchdogIds],
39
+ }));
40
+ }
41
+
42
+ /** Une os findings re-reportados com os carry-over (preserva ids anteriores). */
43
+ export function mergeCarryover(
44
+ merged: MergedFinding[],
45
+ carryover: readonly MergedFinding[],
46
+ ): MergedFinding[] {
47
+ const byKey = new Map(merged.map((finding) => [findingKey(finding), finding]));
48
+ for (const carried of carryover) {
49
+ const existing = byKey.get(findingKey(carried));
50
+ if (existing) {
51
+ for (const watchdog of carried.watchdogs) {
52
+ if (!existing.watchdogs.includes(watchdog))
53
+ existing.watchdogs.push(watchdog);
54
+ }
55
+ continue;
56
+ }
57
+ byKey.set(findingKey(carried), carried);
58
+ merged.push(carried);
59
+ }
60
+ merged.sort((a, b) => {
61
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
62
+ if (bySeverity !== 0) return bySeverity;
63
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
64
+ return (a.line ?? 0) - (b.line ?? 0);
65
+ });
66
+ return merged;
67
+ }