@payconductor/houndsman 0.2.0 → 0.4.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 (107) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/arg-parser.ts +8 -0
  4. package/src/cli/commands/command.ts +23 -0
  5. package/src/cli/commands/context-command.ts +40 -0
  6. package/src/cli/commands/doctor-command.ts +60 -0
  7. package/src/cli/commands/findings-command.ts +38 -0
  8. package/src/cli/commands/help-command.ts +13 -0
  9. package/src/cli/commands/registry.ts +34 -0
  10. package/src/cli/commands/review-command.ts +21 -0
  11. package/src/cli/commands/server-command.ts +37 -0
  12. package/src/cli/commands/setup-command.ts +34 -0
  13. package/src/cli/commands/watchdog-command.ts +34 -0
  14. package/src/cli/help.ts +47 -0
  15. package/src/cli/index.ts +60 -268
  16. package/src/core/arg-parser.ts +48 -0
  17. package/src/core/commands/context.ts +29 -13
  18. package/src/core/commands/findings.ts +185 -260
  19. package/src/core/commands/review.ts +21 -39
  20. package/src/core/commands/scaffold.ts +189 -80
  21. package/src/core/commands/setup.ts +16 -11
  22. package/src/core/commands/update.ts +7 -7
  23. package/src/core/commands/watchdog.ts +8 -80
  24. package/src/core/config.ts +24 -25
  25. package/src/core/defaults.ts +20 -0
  26. package/src/core/diff-source.ts +1 -13
  27. package/src/core/doc-discoverer.ts +25 -15
  28. package/src/core/domain/budget.ts +42 -0
  29. package/src/core/domain/classification.ts +94 -0
  30. package/src/core/domain/diff.ts +53 -0
  31. package/src/core/domain/watchdog.ts +96 -0
  32. package/src/core/engine/carryover.ts +67 -0
  33. package/src/core/engine/houndsman-engine.ts +65 -0
  34. package/src/core/engine/review-scope.ts +95 -0
  35. package/src/core/engine/review-session.ts +79 -0
  36. package/src/core/file-classifier.ts +11 -67
  37. package/src/core/findings-state.ts +193 -100
  38. package/src/core/git-revision.ts +14 -40
  39. package/src/core/git-runner.ts +46 -0
  40. package/src/core/github-review.ts +4 -33
  41. package/src/core/jev/jev-candidates.ts +49 -35
  42. package/src/core/orchestrator.ts +247 -421
  43. package/src/core/paths.ts +26 -11
  44. package/src/core/pr-context.ts +39 -51
  45. package/src/core/project-config.ts +56 -0
  46. package/src/core/renderers/batch-presenter.ts +40 -0
  47. package/src/core/renderers/brief-renderer.ts +23 -0
  48. package/src/core/renderers/findings-presenter.ts +74 -0
  49. package/src/core/renderers/manifest-writer.ts +123 -0
  50. package/src/core/renderers/report-renderer.ts +66 -0
  51. package/src/core/renderers/verify-presenter.ts +50 -0
  52. package/src/core/renderers/watchdog-presenter.ts +100 -0
  53. package/src/core/report-builder.ts +12 -7
  54. package/src/core/run-layout.ts +8 -0
  55. package/src/core/run-status.ts +29 -59
  56. package/src/core/services/clock.ts +12 -0
  57. package/src/core/services/file-classifier.ts +34 -0
  58. package/src/core/services/fix-batch-builder.ts +13 -0
  59. package/src/core/services/gh-client.ts +54 -0
  60. package/src/core/services/git-repository.ts +139 -0
  61. package/src/core/services/project-files.ts +91 -0
  62. package/src/core/services/verifier.ts +58 -0
  63. package/src/core/storage/factory.ts +24 -0
  64. package/src/core/storage/file/file-storage.ts +269 -0
  65. package/src/core/storage/paths.ts +64 -0
  66. package/src/core/storage/repo-identity.ts +70 -0
  67. package/src/core/storage/sqlite/mappers.ts +160 -0
  68. package/src/core/storage/sqlite/schema.ts +108 -0
  69. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  70. package/src/core/storage/types.ts +143 -0
  71. package/src/core/storage/work-area.ts +191 -0
  72. package/src/core/verify.ts +2 -1
  73. package/src/core/watchdog-registry.ts +58 -16
  74. package/src/index.ts +57 -6
  75. package/src/server/artifacts.ts +8 -61
  76. package/src/server/client/ArtifactsPanel.tsx +137 -23
  77. package/src/server/client/FindingCard.tsx +37 -6
  78. package/src/server/client/MarkdownText.tsx +101 -0
  79. package/src/server/client/SubmitModal.tsx +9 -4
  80. package/src/server/client/code-highlight.ts +422 -0
  81. package/src/server/client/constants.ts +130 -24
  82. package/src/server/client/json-highlight.ts +55 -0
  83. package/src/server/client/markdown.ts +153 -0
  84. package/src/server/client/types.ts +23 -0
  85. package/src/server/index.ts +155 -245
  86. package/src/server/run-repository.ts +299 -0
  87. package/src/skill/OUTPUT-FORMAT.md +6 -4
  88. package/src/skill/README.md +22 -14
  89. package/src/skill/SETUP.md +4 -4
  90. package/src/skill/fix.md +5 -5
  91. package/src/skill/hunt.md +27 -18
  92. package/src/skill/train.md +1 -1
  93. package/src/skill/verify.md +5 -5
  94. package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
  95. package/src/tests/baseline.test.ts +555 -493
  96. package/src/tests/cli.test.ts +40 -19
  97. package/src/tests/code-highlight.test.ts +123 -0
  98. package/src/tests/domain.test.ts +328 -0
  99. package/src/tests/findings-commands.test.ts +153 -131
  100. package/src/tests/findings-state.test.ts +6 -3
  101. package/src/tests/fixtures/fake-project.ts +0 -1
  102. package/src/tests/infrastructure.test.ts +205 -0
  103. package/src/tests/markdown.test.ts +89 -0
  104. package/src/tests/review-scripts.test.ts +8 -5
  105. package/src/tests/run-status.test.ts +33 -23
  106. package/src/tests/server.test.ts +108 -75
  107. package/src/tests/storage.test.ts +386 -0
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Engine do houndsman (composition root).
3
+ *
4
+ * Injeta a configuração do projeto e os colaboradores compartilhados (git,
5
+ * classificador, verifier, lote). Comandos e pipelines pedem ao engine em vez
6
+ * de instanciar/importar tudo diretamente.
7
+ */
8
+
9
+ import type { HoundsmanConfig } from "../config";
10
+ import type { ReviewOptions } from "../orchestrator";
11
+ import { ProjectConfig } from "../project-config";
12
+ import { FileClassifier } from "../services/file-classifier";
13
+ import { FixBatchBuilder } from "../services/fix-batch-builder";
14
+ import { GitRepository } from "../services/git-repository";
15
+ import { Verifier } from "../services/verifier";
16
+ import { createStorage } from "../storage/factory";
17
+ import { resolveRepoIdentity } from "../storage/repo-identity";
18
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
19
+ import { ReviewSession } from "./review-session";
20
+
21
+ /** Colaboradores injetáveis (testes/CLI). */
22
+ export type EngineDeps = {
23
+ git?: GitRepository;
24
+ classifier?: FileClassifier;
25
+ verifier?: Verifier;
26
+ fixBatch?: FixBatchBuilder;
27
+ storage?: StorageProvider;
28
+ repo?: RepoIdentity;
29
+ };
30
+
31
+ /** Composition root do motor. */
32
+ export class HoundsmanEngine {
33
+ readonly project: ProjectConfig;
34
+ readonly git: GitRepository;
35
+ readonly classifier: FileClassifier;
36
+ readonly verifier: Verifier;
37
+ readonly fixBatch: FixBatchBuilder;
38
+ readonly storage: StorageProvider;
39
+ readonly repo: RepoIdentity;
40
+
41
+ constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
42
+ this.project = new ProjectConfig(raw);
43
+ this.git = deps.git ?? new GitRepository();
44
+ this.classifier = deps.classifier ?? new FileClassifier(raw);
45
+ this.verifier = deps.verifier ?? new Verifier();
46
+ this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
47
+ this.storage = deps.storage ?? createStorage(raw);
48
+ this.repo = deps.repo ?? resolveRepoIdentity(this.git);
49
+ }
50
+
51
+ /** Configuração crua (contrato do consumidor). */
52
+ get config(): HoundsmanConfig {
53
+ return this.project.raw;
54
+ }
55
+
56
+ /** Abre uma sessão de revisão (prepare/finalize). */
57
+ session(options: ReviewOptions): ReviewSession {
58
+ return ReviewSession.from(this.config, options, this.storage, this.repo);
59
+ }
60
+
61
+ /** Libera o provider (fecha o SQLite e faz checkpoint do WAL). */
62
+ close(): void {
63
+ this.storage.close();
64
+ }
65
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Escopo da revisão (engine) — completo vs incremental.
3
+ *
4
+ * Decide se reconstrói o diff inteiro ou só o delta `prevSha..headSha` e
5
+ * carrega os findings de arquivos inalterados (carry-over).
6
+ */
7
+
8
+ import type { HoundsmanConfig } from "../config";
9
+ import { churnToRecord, collectDiff, type DiffSource } from "../diff-source";
10
+ import type { ClassificationResult } from "../file-classifier";
11
+ import type { FindingsStateFile } from "../findings-state";
12
+ import { collectDiffSince, diffEntries, isAncestor } from "../git-revision";
13
+ import type { MergedFinding } from "../report-builder";
14
+ import { FileClassifier } from "../services/file-classifier";
15
+ import { buildCarryover } from "./carryover";
16
+
17
+ /** Opções que afetam o escopo (subset do `ReviewOptions`). */
18
+ export type ScopeOptions = {
19
+ base: string;
20
+ head: string;
21
+ full: boolean;
22
+ clean: boolean;
23
+ };
24
+
25
+ /** Classificação enriquecida gravada no `classification.json`. */
26
+ export type RunClassification = ClassificationResult & {
27
+ /** Commit revisado (head do diff). */
28
+ headSha?: string;
29
+ /** Modo do prepare: completo ou incremental. */
30
+ mode: "full" | "incremental";
31
+ /** Commit da última revisão (só no incremental). */
32
+ reviewedFrom?: string;
33
+ /** Findings preservados do review anterior (arquivos inalterados). */
34
+ carryover?: MergedFinding[];
35
+ };
36
+
37
+ /** Resultado da resolução de escopo. */
38
+ export type ReviewScope = {
39
+ classification: RunClassification;
40
+ diff: DiffSource;
41
+ reviewedFrom?: string;
42
+ };
43
+
44
+ /** Decide entre review completo e incremental. */
45
+ export class ReviewScopeResolver {
46
+ private readonly options: ScopeOptions;
47
+ private readonly classifier: FileClassifier;
48
+
49
+ constructor(options: ScopeOptions, config: HoundsmanConfig) {
50
+ this.options = options;
51
+ this.classifier = new FileClassifier(config);
52
+ }
53
+
54
+ /**
55
+ * Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
56
+ * sucede (sem rebase) e não veio `--full`/`--clean`.
57
+ */
58
+ resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
59
+ const prevSha = previous?.reviewed?.headSha;
60
+ if (
61
+ this.options.full ||
62
+ this.options.clean ||
63
+ !prevSha ||
64
+ prevSha === headSha ||
65
+ !isAncestor(prevSha, headSha)
66
+ ) {
67
+ const diff = collectDiff(this.options.base, this.options.head);
68
+ return {
69
+ diff,
70
+ classification: {
71
+ ...this.classifier
72
+ .classifyRange(this.options.base, this.options.head, diff)
73
+ .toJSON(),
74
+ headSha,
75
+ mode: "full",
76
+ },
77
+ };
78
+ }
79
+
80
+ const range = `${prevSha}..${headSha}`;
81
+ const diff = collectDiffSince(prevSha, headSha);
82
+ const entries = diffEntries(range);
83
+ const changedPaths = new Set(entries.map((entry) => entry.path));
84
+ const classification: RunClassification = {
85
+ ...this.classifier.classify(entries, churnToRecord(diff.churn)).toJSON(),
86
+ base: prevSha,
87
+ head: this.options.head,
88
+ headSha,
89
+ mode: "incremental",
90
+ reviewedFrom: prevSha,
91
+ carryover: buildCarryover(previous, changedPaths),
92
+ };
93
+ return { classification, diff, reviewedFrom: prevSha };
94
+ }
95
+ }