@payconductor/houndsman 0.8.0 → 0.10.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/core/ai/ai-client.ts +21 -0
  3. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  4. package/src/core/ai/features/technical-summarizer.ts +43 -0
  5. package/src/core/ai/index.ts +4 -1
  6. package/src/core/ai/prompts/context.ts +3 -3
  7. package/src/core/ai/prompts/technical.ts +30 -0
  8. package/src/core/ai/provider-capabilities.ts +45 -0
  9. package/src/core/ai/schemas.ts +19 -3
  10. package/src/core/ai/types.ts +3 -1
  11. package/src/core/brief.ts +25 -4
  12. package/src/core/commands/scaffold.ts +5 -0
  13. package/src/core/config.ts +17 -1
  14. package/src/core/git-runner.ts +8 -1
  15. package/src/core/orchestrator.ts +114 -2
  16. package/src/core/paths.ts +3 -0
  17. package/src/core/pr-context.ts +11 -6
  18. package/src/core/project-config.ts +9 -1
  19. package/src/core/renderers/resume-renderer.ts +17 -34
  20. package/src/core/report-builder.ts +6 -6
  21. package/src/core/services/worktree-manager.ts +29 -5
  22. package/src/core/storage/types.ts +1 -0
  23. package/src/core/technical-summary.ts +398 -0
  24. package/src/harness/agent/agent-factory.ts +1 -0
  25. package/src/harness/agent/review-agent.ts +11 -1
  26. package/src/harness/config/harness-config.ts +6 -1
  27. package/src/index.ts +2 -0
  28. package/src/server/client/App.tsx +2 -1
  29. package/src/server/client/PrsSection.tsx +296 -0
  30. package/src/server/client/ResumePanel.tsx +10 -13
  31. package/src/server/client/RunsSection.tsx +73 -0
  32. package/src/server/client/Sidebar.tsx +171 -413
  33. package/src/server/client/sidebar-sections.tsx +73 -0
  34. package/src/server/client/utils/constants.ts +7 -2
  35. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  36. package/src/server/client/utils/types.ts +1 -0
  37. package/src/server/index.ts +23 -409
  38. package/src/server/styles.css +454 -0
  39. package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
  40. package/src/tests/ai-core.test.ts +71 -0
  41. package/src/tests/ai-features.test.ts +36 -0
  42. package/src/tests/baseline.test.ts +4 -1
  43. package/src/tests/harness-agent.test.ts +19 -0
  44. package/src/tests/review-resume.test.ts +36 -8
  45. package/src/tests/review-scope.test.ts +67 -0
  46. package/src/tests/review-scripts.test.ts +2 -2
  47. package/src/tests/server.test.ts +16 -1
  48. package/src/tests/sidebar-sections.test.ts +94 -0
  49. package/src/tests/technical-summary.test.ts +176 -0
  50. package/src/tests/worktree.test.ts +39 -4
  51. package/assets/logo-legacy.png +0 -0
@@ -119,4 +119,23 @@ describe("ReviewAgent", () => {
119
119
  expect(result.status).toBe("failed");
120
120
  expect(result.error).toContain("429");
121
121
  });
122
+
123
+ test("repassa providerOptions da config em toda chamada", async () => {
124
+ const provider = investigatingProvider();
125
+ const reporter = new SilentReporter();
126
+ const agent = new ReviewAgent(
127
+ fakeAgentDeps(provider, {
128
+ context: fakeAgentContext({ repoRoot: dir }),
129
+ reporter,
130
+ providerOptions: { deepseek: { thinking: { type: "disabled" } } },
131
+ }),
132
+ );
133
+ await agent.run();
134
+ expect(provider.requests.length).toBeGreaterThan(0);
135
+ for (const request of provider.requests) {
136
+ expect(request.providerOptions).toEqual({
137
+ deepseek: { thinking: { type: "disabled" } },
138
+ });
139
+ }
140
+ });
122
141
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Resumo final (`review-resume.md`) — conteúdo de negócio + pontos técnicos,
3
- * determinístico e limitado a `RESUME_MAX_CHARS`.
2
+ * Resumo final (`review-resume.md`) — conteúdo de negócio + pontos técnicos
3
+ * estruturais, determinístico e limitado a `RESUME_MAX_CHARS`.
4
4
  */
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
@@ -10,6 +10,7 @@ import {
10
10
  renderReviewResume,
11
11
  } from "../core/renderers/resume-renderer";
12
12
  import type { MergedFinding } from "../core/report-builder";
13
+ import type { TechnicalSummary } from "../core/technical-summary";
13
14
 
14
15
  function finding(overrides: Partial<MergedFinding> = {}): MergedFinding {
15
16
  return {
@@ -32,8 +33,24 @@ const classification: ResumeClassification = {
32
33
  watchdogs: { alpha: ["src/a.ts"], beta: ["src/b.ts"] },
33
34
  };
34
35
 
36
+ const technical: TechnicalSummary = {
37
+ source: "ai",
38
+ headSha: "abc123",
39
+ overview: "Novo service `bin-lookup` com provider e repository.",
40
+ highlights: [
41
+ {
42
+ area: "Contratos",
43
+ detail: "`BinLookupProvider` (src/bin-lookup/provider.ts)",
44
+ },
45
+ {
46
+ area: "Implementações",
47
+ detail: "`BinLookupService` (src/bin-lookup/service.ts)",
48
+ },
49
+ ],
50
+ };
51
+
35
52
  describe("renderReviewResume", () => {
36
- test("une contexto de negócio e pontos técnicos", () => {
53
+ test("une contexto de negócio e pontos técnicos estruturais", () => {
37
54
  const md = renderReviewResume({
38
55
  branch: "feat/x",
39
56
  date: "2026-10-17",
@@ -48,7 +65,7 @@ describe("renderReviewResume", () => {
48
65
  finding({ severity: "blocking", message: "quebra o cadastro" }),
49
66
  finding({ severity: "attention", file: "src/b.ts", line: undefined }),
50
67
  ],
51
- nameOf: (id) => id.toUpperCase(),
68
+ technical,
52
69
  });
53
70
 
54
71
  expect(md).toContain("# Resumo da revisão — feat/x");
@@ -58,8 +75,11 @@ describe("renderReviewResume", () => {
58
75
  expect(md).toContain("**Por quê:** Reduzir o esforço manual.");
59
76
  expect(md).toContain("- Novo fluxo de cadastro.");
60
77
  expect(md).not.toContain("## Números");
61
- expect(md).toContain("`src/a.ts:1` — quebra o cadastro _(ALPHA)_");
62
- expect(md).toContain("`src/b.ts` — mensagem _(ALPHA)_");
78
+ expect(md).toContain("## Pontos técnicos");
79
+ expect(md).toContain("Novo service `bin-lookup`");
80
+ expect(md).toContain("**Contratos** — `BinLookupProvider`");
81
+ expect(md).toContain("**Implementações** — `BinLookupService`");
82
+ expect(md).not.toContain("quebra o cadastro");
63
83
  expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
64
84
  });
65
85
 
@@ -72,7 +92,7 @@ describe("renderReviewResume", () => {
72
92
 
73
93
  expect(md).toContain("Sem resumo de contexto da PR disponível.");
74
94
  expect(md).toContain("✅ Aprovado");
75
- expect(md).toContain("- Nenhum bloqueio ou ponto de atenção.");
95
+ expect(md).toContain("Sem mudanças estruturais detectadas nesta revisão.");
76
96
  });
77
97
 
78
98
  test("respeita o teto de caracteres", () => {
@@ -89,10 +109,18 @@ describe("renderReviewResume", () => {
89
109
  available: true,
90
110
  objective: "o".repeat(1000),
91
111
  motivation: "m".repeat(1000),
92
- changeSummary: ["a".repeat(200), "b".repeat(200)],
112
+ changeSummary: ["a".repeat(300), "b".repeat(300)],
93
113
  },
94
114
  classification,
95
115
  findings,
116
+ technical: {
117
+ source: "deterministic",
118
+ overview: "v".repeat(600),
119
+ highlights: Array.from({ length: 10 }, (_, index) => ({
120
+ area: `Área ${index}`,
121
+ detail: "x".repeat(300),
122
+ })),
123
+ },
96
124
  });
97
125
 
98
126
  expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
@@ -300,3 +300,70 @@ describe("prepare/finalize — mesmo commit", () => {
300
300
  }
301
301
  }, 30_000);
302
302
  });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Run vazia
306
+ // ---------------------------------------------------------------------------
307
+
308
+ describe("prepare sem alterações", () => {
309
+ test("run recém-criada é descartada (sem run vazia na lista)", async () => {
310
+ const repo = makeRepo();
311
+ const originalCwd = process.cwd();
312
+ const originalHome = process.env.HOUNDSMAN_HOME;
313
+ let storage: SQLiteStorageProvider | null = null;
314
+
315
+ try {
316
+ process.env.HOUNDSMAN_HOME = join(repo.cwd, ".home");
317
+ process.chdir(repo.cwd);
318
+
319
+ storage = new SQLiteStorageProvider(join(repo.cwd, "houndsman.db"));
320
+ const record = storage.getOrCreateReview({
321
+ repo: resolveRepoIdentity(),
322
+ branch: "main",
323
+ slug: "main",
324
+ });
325
+ const opts = {
326
+ ...defaultOptions(),
327
+ base: repo.head,
328
+ head: "HEAD",
329
+ noJev: true,
330
+ };
331
+
332
+ const result = await prepare(
333
+ opts,
334
+ "main",
335
+ fakeHoundsmanConfig,
336
+ reviewContext(storage, record),
337
+ );
338
+ expect(result.messages.join("\n")).toContain("nenhuma run criada");
339
+ expect(storage.getReview(record.id)).toBeNull();
340
+ expect(storage.listArtifacts(record.id)).toHaveLength(0);
341
+
342
+ // Run com artefatos anteriores (contexto) continua existindo.
343
+ const kept = storage.getOrCreateReview({
344
+ repo: resolveRepoIdentity(),
345
+ branch: "main",
346
+ slug: "main",
347
+ });
348
+ storage.writeArtifact(kept.id, {
349
+ name: "context.json",
350
+ kind: "context",
351
+ content: "{}\n",
352
+ });
353
+ await prepare(
354
+ opts,
355
+ "main",
356
+ fakeHoundsmanConfig,
357
+ reviewContext(storage, kept),
358
+ );
359
+ expect(storage.getReview(kept.id)?.status).toBe("review_done");
360
+ expect(storage.readArtifact(kept.id, "context.json")).not.toBeNull();
361
+ } finally {
362
+ storage?.close();
363
+ process.chdir(originalCwd);
364
+ if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
365
+ else process.env.HOUNDSMAN_HOME = originalHome;
366
+ repo.cleanup();
367
+ }
368
+ }, 30_000);
369
+ });
@@ -799,9 +799,9 @@ describe("context-summary", () => {
799
799
  (_, i) => `item ${i} ${"z".repeat(300)}`,
800
800
  ),
801
801
  });
802
- expect(summary?.objective?.length).toBeLessThanOrEqual(200);
802
+ expect(summary?.objective?.length).toBeLessThanOrEqual(400);
803
803
  expect(summary?.objective?.endsWith("…")).toBe(true);
804
- expect(summary?.changeSummary).toHaveLength(4);
804
+ expect(summary?.changeSummary).toHaveLength(6);
805
805
  });
806
806
 
807
807
  test("renderReport coloca a seção de contexto quando presente", () => {
@@ -436,7 +436,19 @@ describe("server API", () => {
436
436
  try {
437
437
  const handler = createRequestHandler(env.config, env);
438
438
  const shell = await get(handler, "/");
439
- expect(await shell.text()).toContain('id="app"');
439
+ const html = await shell.text();
440
+ expect(html).toContain('id="app"');
441
+ expect(html).toContain("/assets/styles.css?v=");
442
+
443
+ // O CSS vive em `styles.css`, servido como arquivo real.
444
+ const css = await get(handler, "/assets/styles.css");
445
+ expect(css.status).toBe(200);
446
+ expect(css.headers.get("content-type")).toContain("text/css");
447
+ const styles = await css.text();
448
+ // Sidebar: rail vertical de seções + painel redimensionável.
449
+ expect(styles).toContain(".side-rail");
450
+ expect(styles).toContain("--side-width");
451
+ expect(styles).not.toContain(".side-tabs");
440
452
 
441
453
  const bundle = await get(handler, "/assets/app.js");
442
454
  expect(bundle.status).toBe(200);
@@ -458,6 +470,9 @@ describe("server API", () => {
458
470
 
459
471
  const bundle = await get(handler, "/assets/app.js");
460
472
  expect(bundle.headers.get("cache-control")).toContain("no-store");
473
+
474
+ const css = await get(handler, "/assets/styles.css");
475
+ expect(css.headers.get("cache-control")).toContain("no-store");
461
476
  } finally {
462
477
  env.cleanup();
463
478
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Navegação da sidebar: registry de seções e preferências de layout.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ DEFAULT_SIDEBAR_SECTION,
8
+ getSidebarSection,
9
+ resolveSidebarSectionId,
10
+ SIDEBAR_SECTIONS,
11
+ type SidebarSectionContext,
12
+ } from "../server/client/sidebar-sections";
13
+ import {
14
+ clampSidebarWidth,
15
+ readSidebarPrefs,
16
+ SIDEBAR_DEFAULT_WIDTH,
17
+ SIDEBAR_MAX_WIDTH,
18
+ SIDEBAR_MIN_WIDTH,
19
+ } from "../server/client/utils/sidebar-prefs";
20
+ import type { RunSummary } from "../server/client/utils/types";
21
+
22
+ function makeRun(slug: string): RunSummary {
23
+ return {
24
+ slug,
25
+ branch: slug,
26
+ summary: {
27
+ total: 0,
28
+ present: 0,
29
+ byDecision: { pending: 0, execute: 0, ignore: 0 },
30
+ byExecState: { pending: 0, in_progress: 0, done: 0, stale: 0 },
31
+ bySeverity: { blocking: 0, attention: 0, suggestion: 0, nit: 0 },
32
+ },
33
+ };
34
+ }
35
+
36
+ function makeContext(runs: RunSummary[]): SidebarSectionContext {
37
+ return {
38
+ runs,
39
+ slug: null,
40
+ onSelect: () => undefined,
41
+ onRefreshRuns: () => undefined,
42
+ active: true,
43
+ };
44
+ }
45
+
46
+ describe("SIDEBAR_SECTIONS", () => {
47
+ test("expõe Execuções e PRs na ordem canônica", () => {
48
+ expect(SIDEBAR_SECTIONS.map((section) => section.id)).toEqual(["runs", "prs"]);
49
+ expect(DEFAULT_SIDEBAR_SECTION).toBe("runs");
50
+ });
51
+
52
+ test("cada seção tem identificação e conteúdo", () => {
53
+ const ids = new Set<string>();
54
+ for (const section of SIDEBAR_SECTIONS) {
55
+ expect(section.id).not.toBe("");
56
+ expect(ids.has(section.id)).toBe(false);
57
+ ids.add(section.id);
58
+ expect(section.label.length).toBeGreaterThan(0);
59
+ expect(section.title.length).toBeGreaterThan(0);
60
+ expect(typeof section.icon).toBe("function");
61
+ expect(typeof section.Content).toBe("function");
62
+ }
63
+ });
64
+
65
+ test("o badge de Execuções reflete a contagem de runs", () => {
66
+ const runs = SIDEBAR_SECTIONS.find((section) => section.id === "runs");
67
+ expect(runs?.badge?.(makeContext([]))).toBeNull();
68
+ expect(runs?.badge?.(makeContext([makeRun("a"), makeRun("b")]))).toBe(2);
69
+ });
70
+
71
+ test("resolve ids salvos e cai no default quando inválidos", () => {
72
+ expect(resolveSidebarSectionId("prs")).toBe("prs");
73
+ expect(resolveSidebarSectionId("desconhecida")).toBe(DEFAULT_SIDEBAR_SECTION);
74
+ expect(resolveSidebarSectionId(null)).toBe(DEFAULT_SIDEBAR_SECTION);
75
+ expect(getSidebarSection("desconhecida").id).toBe(DEFAULT_SIDEBAR_SECTION);
76
+ });
77
+ });
78
+
79
+ describe("preferências da sidebar", () => {
80
+ test("clampa a largura entre mínimo e máximo", () => {
81
+ expect(clampSidebarWidth(200)).toBe(SIDEBAR_MIN_WIDTH);
82
+ expect(clampSidebarWidth(900)).toBe(SIDEBAR_MAX_WIDTH);
83
+ expect(clampSidebarWidth(400)).toBe(400);
84
+ expect(clampSidebarWidth(Number.NaN)).toBe(SIDEBAR_DEFAULT_WIDTH);
85
+ });
86
+
87
+ test("lê defaults sem preferências salvas", () => {
88
+ const prefs = readSidebarPrefs();
89
+ expect(prefs.width).toBeGreaterThanOrEqual(SIDEBAR_MIN_WIDTH);
90
+ expect(prefs.width).toBeLessThanOrEqual(SIDEBAR_MAX_WIDTH);
91
+ expect(typeof prefs.collapsed).toBe("boolean");
92
+ if (prefs.section !== null) expect(typeof prefs.section).toBe("string");
93
+ });
94
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pontos técnicos estruturais do resumo final — builder determinístico e
3
+ * validação do artefato `technical-summary.json`.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { AstAnalysis, AstFacts, AstImpact } from "../core/ast/types";
8
+ import { buildTechnicalSummary, parseTechnicalSummary } from "../core/technical-summary";
9
+
10
+ const facts: AstFacts = {
11
+ language: "typescript",
12
+ symbols: [],
13
+ imports: [],
14
+ exports: [],
15
+ decorators: [],
16
+ hasSyntaxErrors: false,
17
+ };
18
+
19
+ function analysis(partial: Partial<AstAnalysis> = {}): AstAnalysis {
20
+ return {
21
+ facts,
22
+ delta: {
23
+ addedSymbols: [],
24
+ removedSymbols: [],
25
+ modifiedSymbols: [],
26
+ changedSignatures: [],
27
+ addedImports: [],
28
+ removedImports: [],
29
+ changedExports: [],
30
+ },
31
+ ...partial,
32
+ };
33
+ }
34
+
35
+ const classification = {
36
+ files: {
37
+ "src/bin-lookup/service.ts": { status: "added" },
38
+ "src/bin-lookup/provider.ts": { status: "added" },
39
+ },
40
+ churn: {
41
+ "src/bin-lookup/service.ts": { additions: 10, deletions: 0 },
42
+ "src/bin-lookup/provider.ts": { additions: 5, deletions: 0 },
43
+ },
44
+ } as const;
45
+
46
+ describe("buildTechnicalSummary", () => {
47
+ test("extrai contratos, implementações, assinaturas e dependências do AST", () => {
48
+ const ast = new Map<string, AstAnalysis>([
49
+ [
50
+ "src/bin-lookup/service.ts",
51
+ analysis({
52
+ delta: {
53
+ addedSymbols: [
54
+ {
55
+ name: "BinLookupProvider",
56
+ kind: "interface",
57
+ exported: true,
58
+ startLine: 1,
59
+ endLine: 3,
60
+ },
61
+ {
62
+ name: "BinLookupService",
63
+ kind: "class",
64
+ exported: true,
65
+ startLine: 5,
66
+ endLine: 20,
67
+ },
68
+ ],
69
+ removedSymbols: [],
70
+ modifiedSymbols: [],
71
+ changedSignatures: [
72
+ {
73
+ name: "lookup",
74
+ before: "(bin: string)",
75
+ after: "(bin: string, brand: string)",
76
+ exported: true,
77
+ },
78
+ ],
79
+ addedImports: ["./provider", "axios"],
80
+ removedImports: [],
81
+ changedExports: [{ name: "legacy", change: "removed" }],
82
+ },
83
+ }),
84
+ ],
85
+ ]);
86
+
87
+ const summary = buildTechnicalSummary({
88
+ classification,
89
+ ast,
90
+ headSha: "abc",
91
+ });
92
+
93
+ expect(summary.source).toBe("deterministic");
94
+ expect(summary.headSha).toBe("abc");
95
+ const text = summary.highlights.map((item) => `${item.area}: ${item.detail}`);
96
+ expect(text.join("\n")).toContain("Contratos: `BinLookupProvider`");
97
+ expect(text.join("\n")).toContain("Implementações: `BinLookupService`");
98
+ expect(text.join("\n")).toContain("Assinaturas alteradas: `lookup`");
99
+ expect(text.join("\n")).toContain("Novas dependências: `axios`");
100
+ expect(text.join("\n")).not.toContain("./provider");
101
+ expect(text.join("\n")).toContain("Exports alterados: `legacy`");
102
+ });
103
+
104
+ test("sem AST, agrupa arquivos novos por diretório", () => {
105
+ const summary = buildTechnicalSummary({ classification });
106
+ expect(summary.highlights[0]).toEqual({
107
+ area: "Estrutura",
108
+ detail: "Novo diretório `src/bin-lookup` com 2 arquivo(s).",
109
+ });
110
+ });
111
+
112
+ test("registra remoções e impacto", () => {
113
+ const impact: AstImpact = {
114
+ importers: { "src/bin-lookup/service.ts": ["src/a.ts"] },
115
+ affectedFiles: ["src/a.ts"],
116
+ exportedChanges: [
117
+ {
118
+ file: "src/bin-lookup/service.ts",
119
+ symbol: "lookup",
120
+ change: "removed",
121
+ },
122
+ ],
123
+ };
124
+ const summary = buildTechnicalSummary({
125
+ classification: {
126
+ files: {
127
+ "src/old.ts": { status: "deleted" },
128
+ "src/bin-lookup/service.ts": { status: "modified" },
129
+ },
130
+ churn: {},
131
+ },
132
+ impact,
133
+ });
134
+ const text = summary.highlights.map((item) => `${item.area}: ${item.detail}`);
135
+ expect(text.join("\n")).toContain("Remoções: `src/old.ts`");
136
+ expect(text.join("\n")).toContain("Impacto: 1 arquivo(s) importam os alterados");
137
+ });
138
+
139
+ test("não gera destaques quando não há mudança estrutural", () => {
140
+ const summary = buildTechnicalSummary({
141
+ classification: { files: {}, churn: {} },
142
+ });
143
+ expect(summary.highlights).toEqual([]);
144
+ });
145
+ });
146
+
147
+ describe("parseTechnicalSummary", () => {
148
+ test("aceita artefato válido e normaliza o source", () => {
149
+ const summary = parseTechnicalSummary({
150
+ source: "deterministic",
151
+ headSha: "abc",
152
+ overview: "Estrutura",
153
+ highlights: [{ area: "Contratos", detail: "`X`" }],
154
+ });
155
+ expect(summary?.source).toBe("deterministic");
156
+ expect(summary?.highlights[0].area).toBe("Contratos");
157
+ });
158
+
159
+ test("rejeita conteúdo vazio ou inválido", () => {
160
+ expect(parseTechnicalSummary(null)).toBeNull();
161
+ expect(parseTechnicalSummary({})).toBeNull();
162
+ expect(
163
+ parseTechnicalSummary({ highlights: [{ area: "", detail: "" }] }),
164
+ ).toBeNull();
165
+ });
166
+
167
+ test("limita a seis destaques", () => {
168
+ const summary = parseTechnicalSummary({
169
+ highlights: Array.from({ length: 10 }, (_, index) => ({
170
+ area: `Área ${index}`,
171
+ detail: `Detalhe ${index}`,
172
+ })),
173
+ });
174
+ expect(summary?.highlights).toHaveLength(6);
175
+ });
176
+ });
@@ -16,7 +16,7 @@ import {
16
16
  writeFileSync,
17
17
  } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
- import { join } from "node:path";
19
+ import { basename, join } from "node:path";
20
20
  import { parseReviewArgs } from "../core/commands/review";
21
21
  import { HoundsmanEngine } from "../core/engine/houndsman-engine";
22
22
  import { resolveReviewWorkspace } from "../core/engine/review-workspace";
@@ -151,6 +151,41 @@ describe("parseWorktreeList", () => {
151
151
  });
152
152
  });
153
153
 
154
+ describe("nomes de diretório do worktree", () => {
155
+ const manager = new WorktreeManager(
156
+ new GitRepository(createGitRunner(process.cwd())),
157
+ {
158
+ repoKey: "remote:github.com/tradinglabs-io/pay.conductor",
159
+ repoRoot: process.cwd(),
160
+ baseDir: join(tmpdir(), "houndsman-worktrees"),
161
+ },
162
+ );
163
+
164
+ test("repo e branch são slugs curtos com hash", () => {
165
+ // MAX_PATH do Windows: cada segmento fica limitado a legível + hash8.
166
+ expect(basename(manager.baseDir).length).toBeLessThanOrEqual(24 + 9);
167
+ expect(basename(manager.pathFor("main"))).toMatch(/^main-[0-9a-f]{8}$/);
168
+
169
+ const long = manager.pathFor("feat/fetch-branch-balance-split-accounts");
170
+ const name = basename(long);
171
+ expect(name).toMatch(/^[\w.-]{1,20}-[0-9a-f]{8}$/);
172
+ expect(name.length).toBeLessThanOrEqual(20 + 9);
173
+ expect(long.startsWith(manager.baseDir)).toBe(true);
174
+ });
175
+
176
+ test("é determinístico e não colide em prefixos iguais", () => {
177
+ const branch = "feat/fetch-branch-balance-split-accounts";
178
+ expect(manager.pathFor(branch)).toBe(manager.pathFor(branch));
179
+ expect(manager.pathFor(branch)).not.toBe(
180
+ manager.pathFor("feat/fetch-branch-balance-split-other"),
181
+ );
182
+ });
183
+
184
+ test("branches só de caracteres inválidos caem no fallback", () => {
185
+ expect(basename(manager.pathFor("///"))).toMatch(/^branch-[0-9a-f]{8}$/);
186
+ });
187
+ });
188
+
154
189
  describe("WorktreeManager", () => {
155
190
  test("cria detached, reusa o mesmo commit e remove", () => {
156
191
  const sandbox = makeSandbox();
@@ -180,7 +215,7 @@ describe("WorktreeManager", () => {
180
215
  } finally {
181
216
  sandbox.cleanup();
182
217
  }
183
- });
218
+ }, 20_000);
184
219
  });
185
220
 
186
221
  describe("revisões paralelas em worktrees", () => {
@@ -263,7 +298,7 @@ describe("revisões paralelas em worktrees", () => {
263
298
  else process.env.HOUNDSMAN_HOME = originalHome;
264
299
  sandbox.cleanup();
265
300
  }
266
- });
301
+ }, 30_000);
267
302
 
268
303
  test("duas PRs preparam em paralelo sem se contaminar", async () => {
269
304
  const sandbox = makeSandbox();
@@ -365,5 +400,5 @@ describe("revisões paralelas em worktrees", () => {
365
400
  else process.env.HOUNDSMAN_HOME = originalHome;
366
401
  sandbox.cleanup();
367
402
  }
368
- });
403
+ }, 30_000);
369
404
  });
Binary file