@payconductor/houndsman 0.3.0 → 0.5.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 (91) hide show
  1. package/README.md +25 -39
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * View do status na sidebar — timeout só vale quando há heartbeat de execução.
3
+ * `updatedAt` (triagem/sync) não deve simular uma revisão em andamento.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ describeRunStatus,
9
+ REVIEW_TIMEOUT_MS,
10
+ STATUS_TIMEOUT_MS,
11
+ } from "../server/client/utils/constants";
12
+ import type { RunSummary, Summary } from "../server/client/utils/types";
13
+
14
+ const summary: Summary = {
15
+ total: 0,
16
+ present: 0,
17
+ byDecision: { pending: 0, execute: 0, ignore: 0 },
18
+ byExecState: { pending: 0, in_progress: 0, done: 0, stale: 0 },
19
+ bySeverity: { blocking: 0, attention: 0, suggestion: 0, nit: 0 },
20
+ };
21
+
22
+ const NOW = Date.parse("2026-10-17T12:00:00.000Z");
23
+ const iso = (offsetMs: number): string => new Date(NOW + offsetMs).toISOString();
24
+
25
+ function run(overrides: Partial<RunSummary>): RunSummary {
26
+ return {
27
+ slug: "feature-x",
28
+ branch: "feature/x",
29
+ summary,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("describeRunStatus", () => {
35
+ test("heartbeat recente mantém o status de execução", () => {
36
+ expect(
37
+ describeRunStatus(
38
+ run({ status: "reviewing", heartbeatAt: iso(-60_000) }),
39
+ NOW,
40
+ ),
41
+ ).toEqual({ kind: "reviewing", label: "Revisando", timeout: false });
42
+ });
43
+
44
+ test("heartbeat antigo vira timeout", () => {
45
+ expect(
46
+ describeRunStatus(
47
+ run({
48
+ status: "reviewing",
49
+ heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
50
+ }),
51
+ NOW,
52
+ ),
53
+ ).toEqual({ kind: "timeout", label: "Timeout na revisão", timeout: true });
54
+ });
55
+
56
+ test("updatedAt recente não mascara heartbeat antigo", () => {
57
+ expect(
58
+ describeRunStatus(
59
+ run({
60
+ status: "reviewing",
61
+ updatedAt: iso(-1_000),
62
+ heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
63
+ }),
64
+ NOW,
65
+ )?.timeout,
66
+ ).toBe(true);
67
+ });
68
+
69
+ test("sem heartbeat não inventa revisão em andamento", () => {
70
+ expect(
71
+ describeRunStatus(run({ status: "reviewing", updatedAt: iso(-1_000) }), NOW),
72
+ ).toBeNull();
73
+ });
74
+
75
+ test("status terminal aparece normalmente sem heartbeat", () => {
76
+ expect(describeRunStatus(run({ status: "review_done" }), NOW)).toEqual({
77
+ kind: "review_done",
78
+ label: "Revisão pronta",
79
+ timeout: false,
80
+ });
81
+ });
82
+
83
+ test("correção também usa heartbeat para timeout", () => {
84
+ expect(
85
+ describeRunStatus(
86
+ run({
87
+ status: "fixing",
88
+ heartbeatAt: iso(-STATUS_TIMEOUT_MS - 1_000),
89
+ }),
90
+ NOW,
91
+ ),
92
+ ).toEqual({ kind: "timeout", label: "Timeout na correção", timeout: true });
93
+ });
94
+ });
@@ -7,8 +7,11 @@ import {
7
7
  pingFindingsStatus,
8
8
  readRunStatus,
9
9
  statusAfterFindingsChange,
10
+ touchRunStatus,
10
11
  writeRunStatus,
11
12
  } from "../core/run-status";
13
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
14
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
12
15
 
13
16
  function findingState(partial: Partial<FindingState>): FindingState {
14
17
  return {
@@ -45,53 +48,113 @@ function stateOf(findings: FindingState[]): FindingsStateFile {
45
48
  };
46
49
  }
47
50
 
48
- /** Diretório temporário isolado. */
49
- function withTempDir(fn: (dir: string) => void): void {
51
+ /** Banco temporário isolado + review da branch padrão. */
52
+ function withStorage(
53
+ fn: (storage: SQLiteStorageProvider, reviewId: string) => void,
54
+ ): void {
50
55
  const dir = mkdtempSync(join(tmpdir(), "houndsman-run-status-"));
56
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
51
57
  try {
52
- fn(dir);
58
+ const review = storage.getOrCreateReview({
59
+ repo: buildRepoIdentity(null, dir),
60
+ branch: "feature/x",
61
+ slug: "feature-x",
62
+ });
63
+ fn(storage, review.id);
53
64
  } finally {
65
+ storage.close();
54
66
  rmSync(dir, { recursive: true, force: true });
55
67
  }
56
68
  }
57
69
 
58
- describe("run-status — arquivo", () => {
70
+ describe("run-status — storage", () => {
59
71
  test("grava, lê e preserva/restaura o startedAt", () => {
60
- withTempDir((dir) => {
61
- writeRunStatus(dir, {
62
- branch: "feature/x",
72
+ withStorage((storage, reviewId) => {
73
+ writeRunStatus(storage, reviewId, {
63
74
  status: "reviewing",
64
75
  resetStartedAt: true,
65
76
  now: "2026-01-01T00:00:00.000Z",
66
77
  });
67
- const first = readRunStatus(dir);
78
+ const first = readRunStatus(storage, reviewId);
68
79
  expect(first?.status).toBe("reviewing");
69
80
  expect(first?.slug).toBe("feature-x");
70
81
  expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
82
+ expect(first?.heartbeatAt).toBe("2026-01-01T00:00:00.000Z");
71
83
 
72
- writeRunStatus(dir, {
73
- branch: "feature/x",
84
+ writeRunStatus(storage, reviewId, {
74
85
  status: "review_done",
75
86
  now: "2026-01-01T00:10:00.000Z",
76
87
  });
77
- const second = readRunStatus(dir);
88
+ const second = readRunStatus(storage, reviewId);
78
89
  expect(second?.status).toBe("review_done");
79
90
  expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
91
+ expect(second?.heartbeatAt).toBeUndefined();
80
92
  expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
81
93
 
82
- writeRunStatus(dir, {
83
- branch: "feature/x",
94
+ writeRunStatus(storage, reviewId, {
84
95
  status: "reviewing",
85
96
  resetStartedAt: true,
86
97
  now: "2026-01-01T00:20:00.000Z",
87
98
  });
88
- expect(readRunStatus(dir)?.startedAt).toBe("2026-01-01T00:20:00.000Z");
99
+ expect(readRunStatus(storage, reviewId)?.startedAt).toBe(
100
+ "2026-01-01T00:20:00.000Z",
101
+ );
102
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
103
+ "2026-01-01T00:20:00.000Z",
104
+ );
105
+ });
106
+ });
107
+
108
+ test("touch renova o heartbeat apenas em execução ativa", () => {
109
+ withStorage((storage, reviewId) => {
110
+ writeRunStatus(storage, reviewId, {
111
+ status: "reviewing",
112
+ resetStartedAt: true,
113
+ now: "2026-01-01T00:00:00.000Z",
114
+ });
115
+ touchRunStatus(storage, reviewId, "2026-01-01T00:05:00.000Z");
116
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
117
+ "2026-01-01T00:05:00.000Z",
118
+ );
119
+
120
+ writeRunStatus(storage, reviewId, {
121
+ status: "review_done",
122
+ now: "2026-01-01T00:10:00.000Z",
123
+ });
124
+ touchRunStatus(storage, reviewId, "2026-01-01T00:15:00.000Z");
125
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBeUndefined();
126
+ });
127
+ });
128
+
129
+ test("triagem em reviewing não renova o heartbeat; fix em andamento sim", () => {
130
+ withStorage((storage, reviewId) => {
131
+ writeRunStatus(storage, reviewId, {
132
+ status: "reviewing",
133
+ resetStartedAt: true,
134
+ now: "2026-01-01T00:00:00.000Z",
135
+ });
136
+
137
+ // Triagem (decision/comment) mantém reviewing — não é execução.
138
+ pingFindingsStatus(storage, reviewId, stateOf([]));
139
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
140
+ "2026-01-01T00:00:00.000Z",
141
+ );
142
+
143
+ // Correção em andamento renova o heartbeat.
144
+ pingFindingsStatus(
145
+ storage,
146
+ reviewId,
147
+ stateOf([findingState({ execState: "in_progress" })]),
148
+ );
149
+ const fixed = readRunStatus(storage, reviewId);
150
+ expect(fixed?.status).toBe("fixing");
151
+ expect(fixed?.heartbeatAt).not.toBe("2026-01-01T00:00:00.000Z");
89
152
  });
90
153
  });
91
154
 
92
- test("readRunStatus devolve null sem arquivo", () => {
93
- withTempDir((dir) => {
94
- expect(readRunStatus(dir)).toBeNull();
155
+ test("readRunStatus devolve null para review inexistente", () => {
156
+ withStorage((storage) => {
157
+ expect(readRunStatus(storage, "missing")).toBeNull();
95
158
  });
96
159
  });
97
160
  });
@@ -127,7 +190,7 @@ describe("statusAfterFindingsChange", () => {
127
190
  expect(statusAfterFindingsChange("review_done", state)).toBe("fix_done");
128
191
  });
129
192
 
130
- test("sem run.json e sem fix → fallback review_done", () => {
193
+ test("sem review e sem fix → fallback review_done", () => {
131
194
  const state = stateOf([
132
195
  findingState({ decision: "pending", execState: "pending" }),
133
196
  ]);
@@ -136,12 +199,12 @@ describe("statusAfterFindingsChange", () => {
136
199
  });
137
200
 
138
201
  describe("pingFindingsStatus", () => {
139
- test("deriva do estado e grava o run.json", () => {
140
- withTempDir((dir) => {
202
+ test("deriva do estado e grava o status na review", () => {
203
+ withStorage((storage, reviewId) => {
141
204
  const state = stateOf([findingState({ execState: "in_progress" })]);
142
- const file = pingFindingsStatus(dir, state);
143
- expect(file.status).toBe("fixing");
144
- expect(readRunStatus(dir)?.status).toBe("fixing");
205
+ const review = pingFindingsStatus(storage, reviewId, state);
206
+ expect(review.status).toBe("fixing");
207
+ expect(readRunStatus(storage, reviewId)?.status).toBe("fixing");
145
208
  });
146
209
  });
147
210
  });
@@ -1,30 +1,31 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { HoundsmanConfig } from "../core/config";
6
+ import { buildStateFromFindings, fileReader } from "../core/findings-state";
7
+ import { dedupeFindings, parseWatchdogReport } from "../core/report-builder";
8
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
9
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
10
+ import type { RepoIdentity } from "../core/storage/types";
6
11
  import { createRequestHandler, startServer } from "../server";
7
- import { readRunFile, runDirForSlug } from "../server/artifacts";
12
+ import { isSlug } from "../server/artifacts";
8
13
  import { fakeHoundsmanConfig } from "./fixtures/fake-project";
9
14
 
10
- /** Run fake em disco + config apontando para ela. */
15
+ /** Ambiente fake: banco temporário + review `main` com um finding. */
11
16
  function makeEnv(): {
12
- artifactDir: string;
17
+ storage: SQLiteStorageProvider;
18
+ repo: RepoIdentity;
19
+ reviewId: string;
13
20
  config: HoundsmanConfig;
14
21
  cleanup: () => void;
15
22
  } {
16
- const artifactDir = mkdtempSync(join(tmpdir(), "houndsman-server-"));
17
- const outDir = join(artifactDir, "main");
18
- mkdirSync(join(outDir, "findings"), { recursive: true });
19
- writeFileSync(join(outDir, "manifest.json"), JSON.stringify({ branch: "main" }));
20
- writeFileSync(
21
- join(outDir, "classification.json"),
22
- JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
23
- );
24
- writeFileSync(join(outDir, "review.md"), "# Review\n");
25
- writeFileSync(
26
- join(outDir, "findings", "alpha.json"),
27
- JSON.stringify({
23
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-server-"));
24
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
25
+ const repo = buildRepoIdentity(null, dir);
26
+ const review = storage.getOrCreateReview({ repo, branch: "main", slug: "main" });
27
+ const report = parseWatchdogReport(
28
+ {
28
29
  watchdogId: "alpha",
29
30
  reviewedFiles: ["src/alpha/f.ts"],
30
31
  skipped: [],
@@ -38,58 +39,64 @@ function makeEnv(): {
38
39
  action: "tipar",
39
40
  },
40
41
  ],
41
- }),
42
+ },
43
+ "alpha.json",
42
44
  );
45
+ if (report) {
46
+ const state = buildStateFromFindings(dedupeFindings([report]), {
47
+ branch: "main",
48
+ readFile: fileReader(process.cwd()),
49
+ });
50
+ // O arquivo fake não existe no cwd → sem âncora. Fixa a linha para o
51
+ // cenário de comentário inline no teste de submit.
52
+ for (const finding of Object.values(state.findings)) finding.line = 2;
53
+ storage.writeFindings(review.id, state);
54
+ }
55
+ storage.writeArtifact(review.id, {
56
+ name: "manifest.json",
57
+ kind: "manifest",
58
+ content: JSON.stringify({ branch: "main" }),
59
+ });
60
+ storage.writeArtifact(review.id, {
61
+ name: "classification.json",
62
+ kind: "classification",
63
+ content: JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
64
+ });
65
+ storage.writeArtifact(review.id, {
66
+ name: "review.md",
67
+ kind: "review",
68
+ content: "# Review\n",
69
+ });
70
+ storage.writeArtifact(review.id, {
71
+ name: "briefs/alpha.md",
72
+ kind: "brief",
73
+ content: "# Brief alpha\n",
74
+ });
43
75
  return {
44
- artifactDir,
45
- config: { ...fakeHoundsmanConfig, artifactDir },
46
- cleanup: () => rmSync(artifactDir, { recursive: true, force: true }),
76
+ storage,
77
+ repo,
78
+ reviewId: review.id,
79
+ config: fakeHoundsmanConfig,
80
+ cleanup: () => {
81
+ storage.close();
82
+ rmSync(dir, { recursive: true, force: true });
83
+ },
47
84
  };
48
85
  }
49
86
 
50
- /** Semeia um `state.json` com um finding inline (tem linha) na run. */
51
- function seedState(artifactDir: string, branch: string): void {
52
- writeFileSync(
53
- join(artifactDir, branch, "state.json"),
54
- JSON.stringify({
55
- version: 2,
56
- branch,
57
- slug: branch,
58
- updatedAt: "2026-01-01T00:00:00.000Z",
59
- order: ["f1"],
60
- findings: {
61
- f1: {
62
- id: "f1",
63
- fingerprint: "fp1",
64
- watchdogIds: ["alpha"],
65
- rule: "Nunca usar any",
66
- severity: "blocking",
67
- file: "src/alpha/f.ts",
68
- line: 2,
69
- message: "any detectado",
70
- action: "tipar",
71
- anchor: null,
72
- count: 1,
73
- present: true,
74
- decision: "pending",
75
- execState: "pending",
76
- createdAt: "2026-01-01T00:00:00.000Z",
77
- updatedAt: "2026-01-01T00:00:00.000Z",
78
- history: [],
79
- },
80
- },
81
- }),
82
- );
83
- }
84
-
85
87
  const get = (handler: (r: Request) => Promise<Response>, path: string) =>
86
88
  handler(new Request(`http://localhost${path}`));
87
89
 
90
+ /** Resposta OK do `gh` com um JSON. */
91
+ function ghJson(payload: unknown) {
92
+ return { ok: true, code: 0, stderr: "", stdout: JSON.stringify(payload) };
93
+ }
94
+
88
95
  describe("server API", () => {
89
96
  test("GET /api/runs lista as runs com resumo", async () => {
90
97
  const env = makeEnv();
91
98
  try {
92
- const handler = createRequestHandler(env.config);
99
+ const handler = createRequestHandler(env.config, env);
93
100
  const res = await get(handler, "/api/runs");
94
101
  expect(res.status).toBe(200);
95
102
  const body = (await res.json()) as {
@@ -106,7 +113,7 @@ describe("server API", () => {
106
113
  test("GET findings reconstrói do artefato e PATCH atualiza o estado", async () => {
107
114
  const env = makeEnv();
108
115
  try {
109
- const handler = createRequestHandler(env.config);
116
+ const handler = createRequestHandler(env.config, env);
110
117
  const list = await get(handler, "/api/runs/main/findings?all=1");
111
118
  const body = (await list.json()) as {
112
119
  findings: { id: string; decision: string }[];
@@ -148,7 +155,7 @@ describe("server API", () => {
148
155
  test("PATCH expõe o run status em /api/runs", async () => {
149
156
  const env = makeEnv();
150
157
  try {
151
- const handler = createRequestHandler(env.config);
158
+ const handler = createRequestHandler(env.config, env);
152
159
  const before = (await (await get(handler, "/api/runs")).json()) as {
153
160
  runs: { status: string | null }[];
154
161
  };
@@ -178,7 +185,7 @@ describe("server API", () => {
178
185
  test("POST sync devolve o resultado da reancoragem", async () => {
179
186
  const env = makeEnv();
180
187
  try {
181
- const handler = createRequestHandler(env.config);
188
+ const handler = createRequestHandler(env.config, env);
182
189
  await get(handler, "/api/runs/main/findings?all=1");
183
190
  const res = await handler(
184
191
  new Request("http://localhost/api/runs/main/sync", { method: "POST" }),
@@ -195,6 +202,8 @@ describe("server API", () => {
195
202
  const env = makeEnv();
196
203
  try {
197
204
  const handler = createRequestHandler(env.config, {
205
+ storage: env.storage,
206
+ repo: env.repo,
198
207
  ghRunner: () => ({
199
208
  ok: true,
200
209
  code: 0,
@@ -224,29 +233,80 @@ describe("server API", () => {
224
233
  test("POST submit posta comentários e grava o vínculo no estado", async () => {
225
234
  const env = makeEnv();
226
235
  try {
227
- seedState(env.artifactDir, "main");
228
236
  const handler = createRequestHandler(env.config, {
229
- ghRunner: (args) =>
230
- args[0] === "pr"
231
- ? {
232
- ok: true,
233
- code: 0,
234
- stderr: "",
235
- stdout: JSON.stringify({
236
- number: 12,
237
- url: "https://github.com/acme/app/pull/12",
238
- headRefOid: "deadbeef",
239
- }),
240
- }
241
- : {
242
- ok: true,
243
- code: 0,
244
- stderr: "",
245
- stdout: JSON.stringify({
246
- id: 501,
247
- html_url: "https://x/501",
248
- }),
249
- },
237
+ storage: env.storage,
238
+ repo: env.repo,
239
+ ghRunner: (args) => {
240
+ if (args[0] === "pr") {
241
+ return ghJson({
242
+ number: 12,
243
+ url: "https://github.com/acme/app/pull/12",
244
+ headRefOid: "deadbeef",
245
+ });
246
+ }
247
+ if (args[1] === "graphql") {
248
+ const query = args.find((arg) => arg.startsWith("query=")) ?? "";
249
+ if (query.includes("states: [PENDING]")) {
250
+ return ghJson({
251
+ data: {
252
+ repository: {
253
+ pullRequest: {
254
+ id: "PR_1",
255
+ reviews: { nodes: [] },
256
+ },
257
+ },
258
+ },
259
+ });
260
+ }
261
+ if (query.includes("addPullRequestReview(input")) {
262
+ return ghJson({
263
+ data: {
264
+ addPullRequestReview: {
265
+ pullRequestReview: {
266
+ id: "REV_NEW",
267
+ databaseId: 11,
268
+ resourcePath:
269
+ "/acme/app/pull/12#pullrequestreview-11",
270
+ },
271
+ },
272
+ },
273
+ });
274
+ }
275
+ if (query.includes("addPullRequestReviewThread")) {
276
+ return ghJson({
277
+ data: {
278
+ addPullRequestReviewThread: {
279
+ thread: {
280
+ comments: {
281
+ nodes: [
282
+ {
283
+ databaseId: 501,
284
+ resourcePath:
285
+ "/acme/app/pull/12#discussion_r501",
286
+ },
287
+ ],
288
+ },
289
+ },
290
+ },
291
+ },
292
+ });
293
+ }
294
+ if (query.includes("submitPullRequestReview")) {
295
+ return ghJson({
296
+ data: {
297
+ submitPullRequestReview: {
298
+ pullRequestReview: {
299
+ id: "REV_NEW",
300
+ databaseId: 11,
301
+ state: "CHANGES_REQUESTED",
302
+ },
303
+ },
304
+ },
305
+ });
306
+ }
307
+ }
308
+ return { ok: false, code: 404, stderr: "", stdout: "" };
309
+ },
250
310
  });
251
311
  const res = await handler(
252
312
  new Request("http://localhost/api/runs/main/submit", {
@@ -273,10 +333,35 @@ describe("server API", () => {
273
333
  }
274
334
  });
275
335
 
336
+ test("artifacts lista todos os artefatos da run (inclui briefs)", async () => {
337
+ const env = makeEnv();
338
+ try {
339
+ const handler = createRequestHandler(env.config, env);
340
+ const res = await get(handler, "/api/runs/main/artifacts");
341
+ expect(res.status).toBe(200);
342
+ const body = (await res.json()) as {
343
+ artifacts: { name: string; kind: string }[];
344
+ };
345
+ expect(body.artifacts.map((item) => item.name)).toEqual(
346
+ expect.arrayContaining([
347
+ "manifest.json",
348
+ "classification.json",
349
+ "review.md",
350
+ "briefs/alpha.md",
351
+ ]),
352
+ );
353
+ expect(
354
+ body.artifacts.find((item) => item.name === "briefs/alpha.md")?.kind,
355
+ ).toBe("brief");
356
+ } finally {
357
+ env.cleanup();
358
+ }
359
+ });
360
+
276
361
  test("artifacts serve arquivos da run e barra o resto", async () => {
277
362
  const env = makeEnv();
278
363
  try {
279
- const handler = createRequestHandler(env.config);
364
+ const handler = createRequestHandler(env.config, env);
280
365
  const ok = await get(handler, "/api/runs/main/artifacts/manifest.json");
281
366
  expect(ok.status).toBe(200);
282
367
  expect(await ok.json()).toEqual({ branch: "main" });
@@ -285,6 +370,10 @@ describe("server API", () => {
285
370
  expect(review.status).toBe(200);
286
371
  expect(await review.text()).toContain("# Review");
287
372
 
373
+ const brief = await get(handler, "/api/runs/main/artifacts/briefs/alpha.md");
374
+ expect(brief.status).toBe(200);
375
+ expect(await brief.text()).toContain("# Brief alpha");
376
+
288
377
  const denied = await get(handler, "/api/runs/main/artifacts/evil.txt");
289
378
  expect(denied.status).toBe(404);
290
379
 
@@ -295,23 +384,17 @@ describe("server API", () => {
295
384
  }
296
385
  });
297
386
 
298
- test("readRunFile barra path traversal", () => {
299
- const env = makeEnv();
300
- try {
301
- const dir = runDirForSlug(env.artifactDir, "main");
302
- expect(dir).not.toBeNull();
303
- expect(readRunFile(dir as string, "../../../etc/passwd")).toBeNull();
304
- expect(readRunFile(dir as string, "manifest.json")).toContain("main");
305
- expect(runDirForSlug(env.artifactDir, "../main")).toBeNull();
306
- } finally {
307
- env.cleanup();
308
- }
387
+ test("valida o slug usado nas rotas", () => {
388
+ expect(isSlug("main")).toBe(true);
389
+ expect(isSlug("feature-x")).toBe(true);
390
+ expect(isSlug("../main")).toBe(false);
391
+ expect(isSlug("a/b")).toBe(false);
309
392
  });
310
393
 
311
394
  test("GET / serve o shell e /assets/app.js empacota o cliente", async () => {
312
395
  const env = makeEnv();
313
396
  try {
314
- const handler = createRequestHandler(env.config);
397
+ const handler = createRequestHandler(env.config, env);
315
398
  const shell = await get(handler, "/");
316
399
  expect(await shell.text()).toContain('id="app"');
317
400
 
@@ -328,7 +411,7 @@ describe("server API", () => {
328
411
  test("serve sem cache (no-store) e busta o bundle com um build id", async () => {
329
412
  const env = makeEnv();
330
413
  try {
331
- const handler = createRequestHandler(env.config);
414
+ const handler = createRequestHandler(env.config, env);
332
415
  const shell = await get(handler, "/");
333
416
  expect(shell.headers.get("cache-control")).toContain("no-store");
334
417
  expect(await shell.text()).toContain("/assets/app.js?v=");
@@ -346,7 +429,12 @@ describe("startServer", () => {
346
429
  const env = makeEnv();
347
430
  let server: ReturnType<typeof startServer>["server"] | undefined;
348
431
  try {
349
- const started = startServer({ config: env.config, port: 0 });
432
+ const started = startServer({
433
+ config: env.config,
434
+ port: 0,
435
+ storage: env.storage,
436
+ repo: env.repo,
437
+ });
350
438
  server = started.server;
351
439
  const res = await fetch(`${started.url}/api/health`);
352
440
  expect(res.status).toBe(200);