@payconductor/houndsman 0.8.0 → 0.11.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.
- package/package.json +1 -1
- package/src/cli/index.ts +9 -1
- package/src/core/ai/ai-client.ts +21 -0
- package/src/core/ai/ai-sdk-model-provider.ts +43 -18
- package/src/core/ai/features/technical-summarizer.ts +43 -0
- package/src/core/ai/index.ts +4 -1
- package/src/core/ai/prompts/base.ts +7 -0
- package/src/core/ai/prompts/context.ts +5 -4
- package/src/core/ai/prompts/technical.ts +31 -0
- package/src/core/ai/provider-capabilities.ts +45 -0
- package/src/core/ai/schemas.ts +19 -3
- package/src/core/ai/types.ts +3 -1
- package/src/core/brief.ts +25 -4
- package/src/core/commands/scaffold.ts +5 -0
- package/src/core/config-loader.ts +18 -4
- package/src/core/config.ts +17 -1
- package/src/core/engine/review-workspace.ts +6 -2
- package/src/core/git-runner.ts +8 -1
- package/src/core/orchestrator.ts +114 -2
- package/src/core/paths.ts +3 -0
- package/src/core/pr-context.ts +11 -6
- package/src/core/project-config.ts +9 -1
- package/src/core/renderers/resume-renderer.ts +17 -34
- package/src/core/report-builder.ts +6 -6
- package/src/core/services/worktree-manager.ts +90 -7
- package/src/core/storage/types.ts +1 -0
- package/src/core/technical-summary.ts +398 -0
- package/src/harness/agent/agent-factory.ts +1 -0
- package/src/harness/agent/review-agent.ts +11 -1
- package/src/harness/config/harness-config.ts +6 -1
- package/src/index.ts +2 -0
- package/src/server/client/App.tsx +2 -1
- package/src/server/client/PrsSection.tsx +296 -0
- package/src/server/client/ResumePanel.tsx +10 -13
- package/src/server/client/RunsSection.tsx +73 -0
- package/src/server/client/Sidebar.tsx +171 -413
- package/src/server/client/sidebar-sections.tsx +73 -0
- package/src/server/client/utils/constants.ts +7 -2
- package/src/server/client/utils/sidebar-prefs.ts +64 -0
- package/src/server/client/utils/types.ts +1 -0
- package/src/server/index.ts +23 -409
- package/src/server/styles.css +454 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
- package/src/tests/ai-core.test.ts +71 -0
- package/src/tests/ai-features.test.ts +47 -0
- package/src/tests/baseline.test.ts +4 -1
- package/src/tests/harness-agent.test.ts +19 -0
- package/src/tests/review-resume.test.ts +36 -8
- package/src/tests/review-scope.test.ts +67 -0
- package/src/tests/review-scripts.test.ts +2 -2
- package/src/tests/server.test.ts +16 -1
- package/src/tests/sidebar-sections.test.ts +94 -0
- package/src/tests/technical-summary.test.ts +176 -0
- package/src/tests/worktree.test.ts +110 -5
- package/assets/logo-legacy.png +0 -0
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { defineHoundsmanConfig } from "../core/config";
|
|
29
29
|
import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
|
|
30
30
|
import { displayPath } from "../core/paths";
|
|
31
|
+
import { RESUME_MAX_CHARS } from "../core/renderers/resume-renderer";
|
|
31
32
|
import {
|
|
32
33
|
type MergedFinding,
|
|
33
34
|
renderDomainReport,
|
|
@@ -434,7 +435,9 @@ describe("baseline — artefatos de prepare/finalize", () => {
|
|
|
434
435
|
}
|
|
435
436
|
const resume = storage.readArtifact(review.id, "review-resume.md");
|
|
436
437
|
expect(resume?.kind).toBe("resume");
|
|
437
|
-
expect(
|
|
438
|
+
expect(resume?.content).toContain("## Pontos técnicos");
|
|
439
|
+
expect(resume?.content).toContain("Remoções");
|
|
440
|
+
expect((resume?.content ?? "").length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
|
|
438
441
|
expect(
|
|
439
442
|
normalize(JSON.stringify(storage.readFindings(review.id), null, 2)),
|
|
440
443
|
).toMatchSnapshot("state.json");
|
|
@@ -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
|
-
|
|
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("
|
|
62
|
-
expect(md).toContain("`
|
|
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("
|
|
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(
|
|
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(
|
|
802
|
+
expect(summary?.objective?.length).toBeLessThanOrEqual(400);
|
|
803
803
|
expect(summary?.objective?.endsWith("…")).toBe(true);
|
|
804
|
-
expect(summary?.changeSummary).toHaveLength(
|
|
804
|
+
expect(summary?.changeSummary).toHaveLength(6);
|
|
805
805
|
});
|
|
806
806
|
|
|
807
807
|
test("renderReport coloca a seção de contexto quando presente", () => {
|
package/src/tests/server.test.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
|
@@ -24,7 +24,12 @@ import { createGitRunner } from "../core/git-runner";
|
|
|
24
24
|
import { defaultOptions } from "../core/orchestrator";
|
|
25
25
|
import type { GhRunner } from "../core/services/gh-client";
|
|
26
26
|
import { GitRepository } from "../core/services/git-repository";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
parseWorktreeList,
|
|
29
|
+
syncProjectLayer,
|
|
30
|
+
syncProjectLayerForRoot,
|
|
31
|
+
WorktreeManager,
|
|
32
|
+
} from "../core/services/worktree-manager";
|
|
28
33
|
import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
|
|
29
34
|
import { parseHuntArgs } from "../harness/hunt/hunt-options";
|
|
30
35
|
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
@@ -151,6 +156,106 @@ describe("parseWorktreeList", () => {
|
|
|
151
156
|
});
|
|
152
157
|
});
|
|
153
158
|
|
|
159
|
+
describe("nomes de diretório do worktree", () => {
|
|
160
|
+
const manager = new WorktreeManager(
|
|
161
|
+
new GitRepository(createGitRunner(process.cwd())),
|
|
162
|
+
{
|
|
163
|
+
repoKey: "remote:github.com/tradinglabs-io/pay.conductor",
|
|
164
|
+
repoRoot: process.cwd(),
|
|
165
|
+
baseDir: join(tmpdir(), "houndsman-worktrees"),
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
test("repo e branch são slugs curtos com hash", () => {
|
|
170
|
+
// MAX_PATH do Windows: cada segmento fica limitado a legível + hash8.
|
|
171
|
+
expect(basename(manager.baseDir).length).toBeLessThanOrEqual(24 + 9);
|
|
172
|
+
expect(basename(manager.pathFor("main"))).toMatch(/^main-[0-9a-f]{8}$/);
|
|
173
|
+
|
|
174
|
+
const long = manager.pathFor("feat/fetch-branch-balance-split-accounts");
|
|
175
|
+
const name = basename(long);
|
|
176
|
+
expect(name).toMatch(/^[\w.-]{1,20}-[0-9a-f]{8}$/);
|
|
177
|
+
expect(name.length).toBeLessThanOrEqual(20 + 9);
|
|
178
|
+
expect(long.startsWith(manager.baseDir)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("é determinístico e não colide em prefixos iguais", () => {
|
|
182
|
+
const branch = "feat/fetch-branch-balance-split-accounts";
|
|
183
|
+
expect(manager.pathFor(branch)).toBe(manager.pathFor(branch));
|
|
184
|
+
expect(manager.pathFor(branch)).not.toBe(
|
|
185
|
+
manager.pathFor("feat/fetch-branch-balance-split-other"),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("branches só de caracteres inválidos caem no fallback", () => {
|
|
190
|
+
expect(basename(manager.pathFor("///"))).toMatch(/^branch-[0-9a-f]{8}$/);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("camada gerada no worktree", () => {
|
|
195
|
+
test("espelha `.houndsman/core` do clone principal e ignora origem ausente", () => {
|
|
196
|
+
const sandbox = makeSandbox();
|
|
197
|
+
try {
|
|
198
|
+
const worktree = join(sandbox.root, "wt-layer");
|
|
199
|
+
mkdirSync(worktree, { recursive: true });
|
|
200
|
+
mkdirSync(join(sandbox.repo, ".houndsman", "core"), { recursive: true });
|
|
201
|
+
writeFileSync(
|
|
202
|
+
join(sandbox.repo, ".houndsman", "core", "types.ts"),
|
|
203
|
+
"export {};\n",
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(syncProjectLayer(sandbox.repo, worktree)).toBe(true);
|
|
207
|
+
expect(existsSync(join(worktree, ".houndsman", "core", "types.ts"))).toBe(
|
|
208
|
+
true,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Sem camada na origem: no-op (fail-open).
|
|
212
|
+
const empty = join(sandbox.root, "empty");
|
|
213
|
+
mkdirSync(empty, { recursive: true });
|
|
214
|
+
expect(syncProjectLayer(empty, worktree)).toBe(false);
|
|
215
|
+
} finally {
|
|
216
|
+
sandbox.cleanup();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("ensure copia a camada e o auto-reparo repõe num worktree existente", () => {
|
|
221
|
+
const sandbox = makeSandbox();
|
|
222
|
+
try {
|
|
223
|
+
mkdirSync(join(sandbox.repo, ".houndsman", "core"), { recursive: true });
|
|
224
|
+
writeFileSync(
|
|
225
|
+
join(sandbox.repo, ".houndsman", "core", "types.ts"),
|
|
226
|
+
"export {};\n",
|
|
227
|
+
);
|
|
228
|
+
const manager = new WorktreeManager(
|
|
229
|
+
new GitRepository(createGitRunner(sandbox.repo)),
|
|
230
|
+
{
|
|
231
|
+
repoKey: "path:fake",
|
|
232
|
+
repoRoot: sandbox.repo,
|
|
233
|
+
baseDir: sandbox.home,
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const ensured = manager.ensure({
|
|
238
|
+
branch: "feature",
|
|
239
|
+
ref: sandbox.featureSha,
|
|
240
|
+
});
|
|
241
|
+
const layer = join(ensured.path, ".houndsman", "core", "types.ts");
|
|
242
|
+
expect(existsSync(layer)).toBe(true);
|
|
243
|
+
|
|
244
|
+
// Comando rodando no worktree (sem `--pr`) também se auto-repara.
|
|
245
|
+
rmSync(join(ensured.path, ".houndsman", "core"), {
|
|
246
|
+
recursive: true,
|
|
247
|
+
force: true,
|
|
248
|
+
});
|
|
249
|
+
expect(syncProjectLayerForRoot(ensured.path)).toBe(true);
|
|
250
|
+
expect(existsSync(layer)).toBe(true);
|
|
251
|
+
|
|
252
|
+
manager.remove("feature");
|
|
253
|
+
} finally {
|
|
254
|
+
sandbox.cleanup();
|
|
255
|
+
}
|
|
256
|
+
}, 20_000);
|
|
257
|
+
});
|
|
258
|
+
|
|
154
259
|
describe("WorktreeManager", () => {
|
|
155
260
|
test("cria detached, reusa o mesmo commit e remove", () => {
|
|
156
261
|
const sandbox = makeSandbox();
|
|
@@ -180,7 +285,7 @@ describe("WorktreeManager", () => {
|
|
|
180
285
|
} finally {
|
|
181
286
|
sandbox.cleanup();
|
|
182
287
|
}
|
|
183
|
-
});
|
|
288
|
+
}, 20_000);
|
|
184
289
|
});
|
|
185
290
|
|
|
186
291
|
describe("revisões paralelas em worktrees", () => {
|
|
@@ -263,7 +368,7 @@ describe("revisões paralelas em worktrees", () => {
|
|
|
263
368
|
else process.env.HOUNDSMAN_HOME = originalHome;
|
|
264
369
|
sandbox.cleanup();
|
|
265
370
|
}
|
|
266
|
-
});
|
|
371
|
+
}, 30_000);
|
|
267
372
|
|
|
268
373
|
test("duas PRs preparam em paralelo sem se contaminar", async () => {
|
|
269
374
|
const sandbox = makeSandbox();
|
|
@@ -365,5 +470,5 @@ describe("revisões paralelas em worktrees", () => {
|
|
|
365
470
|
else process.env.HOUNDSMAN_HOME = originalHome;
|
|
366
471
|
sandbox.cleanup();
|
|
367
472
|
}
|
|
368
|
-
});
|
|
473
|
+
}, 30_000);
|
|
369
474
|
});
|
package/assets/logo-legacy.png
DELETED
|
Binary file
|