@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,89 @@
1
+ /**
2
+ * Markdown mínimo do `action` — blocos, inline e sanitização de links.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { parseInline, parseMarkdown, safeUrl } from "../server/client/markdown";
7
+
8
+ describe("parseMarkdown", () => {
9
+ test("separa parágrafos, listas, quote e bloco de código", () => {
10
+ const blocks = parseMarkdown(
11
+ "Primeira linha\nsegunda linha.\n\n- um\n- dois\n\n1. passo\n2. outro\n\n> nota\n\n```ts\nconst x = 1;\n```",
12
+ );
13
+ expect(blocks.map((block) => block.type)).toEqual([
14
+ "paragraph",
15
+ "list",
16
+ "list",
17
+ "quote",
18
+ "code",
19
+ ]);
20
+ expect(blocks[0]).toEqual({
21
+ type: "paragraph",
22
+ text: "Primeira linha\nsegunda linha.",
23
+ });
24
+ expect(blocks[1]).toEqual({
25
+ type: "list",
26
+ ordered: false,
27
+ items: ["um", "dois"],
28
+ });
29
+ expect(blocks[2]).toEqual({
30
+ type: "list",
31
+ ordered: true,
32
+ items: ["passo", "outro"],
33
+ });
34
+ expect(blocks[3]).toEqual({ type: "quote", text: "nota" });
35
+ expect(blocks[4]).toEqual({
36
+ type: "code",
37
+ language: "ts",
38
+ code: "const x = 1;",
39
+ });
40
+ });
41
+
42
+ test("texto vazio não gera blocos", () => {
43
+ expect(parseMarkdown(" \n\n ")).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe("parseInline", () => {
48
+ test("reconhece code, bold, itálico e link na ordem", () => {
49
+ const tokens = parseInline(
50
+ "Use `npm run ci` e **sempre** valide _tudo_ em [docs](https://x.dev).",
51
+ );
52
+ expect(tokens).toEqual([
53
+ { type: "text", text: "Use " },
54
+ { type: "code", text: "npm run ci" },
55
+ { type: "text", text: " e " },
56
+ { type: "strong", text: "sempre" },
57
+ { type: "text", text: " valide " },
58
+ { type: "em", text: "tudo" },
59
+ { type: "text", text: " em " },
60
+ { type: "link", text: "docs", url: "https://x.dev" },
61
+ { type: "text", text: "." },
62
+ ]);
63
+ });
64
+
65
+ test("não confunde underscore de identificador com itálico", () => {
66
+ expect(parseInline("foo_bar_baz")).toEqual([
67
+ { type: "text", text: "foo_bar_baz" },
68
+ ]);
69
+ });
70
+
71
+ test("asterisco simples também vira itálico", () => {
72
+ expect(parseInline("seja *rápido*")).toEqual([
73
+ { type: "text", text: "seja " },
74
+ { type: "em", text: "rápido" },
75
+ ]);
76
+ });
77
+ });
78
+
79
+ describe("safeUrl", () => {
80
+ test("aceita http(s)/mailto e caminho relativo; barra o resto", () => {
81
+ expect(safeUrl("https://x.dev")).toBe("https://x.dev");
82
+ expect(safeUrl("http://x.dev")).toBe("http://x.dev");
83
+ expect(safeUrl("mailto:a@b.c")).toBe("mailto:a@b.c");
84
+ expect(safeUrl("/docs")).toBe("/docs");
85
+ expect(safeUrl("javascript:alert(1)")).toBeNull();
86
+ expect(safeUrl("//evil.dev")).toBeNull();
87
+ expect(safeUrl("data:text/html,x")).toBeNull();
88
+ });
89
+ });
@@ -1150,13 +1150,16 @@ describe("jev-candidates", () => {
1150
1150
 
1151
1151
  describe("parseReviewArgs", () => {
1152
1152
  test("lê flags de valor e booleanas", () => {
1153
- const opts = parseReviewArgs(
1154
- ["--base", "develop", "--branch", "feature/x", "--finalize", "--clean"],
1155
- "tmp/artifacts",
1156
- );
1153
+ const opts = parseReviewArgs([
1154
+ "--base",
1155
+ "develop",
1156
+ "--branch",
1157
+ "feature/x",
1158
+ "--finalize",
1159
+ "--clean",
1160
+ ]);
1157
1161
  expect(opts.base).toBe("develop");
1158
1162
  expect(opts.branch).toBe("feature/x");
1159
- expect(opts.dir).toBe("tmp/artifacts");
1160
1163
  expect(opts.finalize).toBe(true);
1161
1164
  expect(opts.clean).toBe(true);
1162
1165
  expect(opts.failOnBlocking).toBe(false);
@@ -9,6 +9,8 @@ import {
9
9
  statusAfterFindingsChange,
10
10
  writeRunStatus,
11
11
  } from "../core/run-status";
12
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
13
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
12
14
 
13
15
  function findingState(partial: Partial<FindingState>): FindingState {
14
16
  return {
@@ -45,53 +47,61 @@ function stateOf(findings: FindingState[]): FindingsStateFile {
45
47
  };
46
48
  }
47
49
 
48
- /** Diretório temporário isolado. */
49
- function withTempDir(fn: (dir: string) => void): void {
50
+ /** Banco temporário isolado + review da branch padrão. */
51
+ function withStorage(
52
+ fn: (storage: SQLiteStorageProvider, reviewId: string) => void,
53
+ ): void {
50
54
  const dir = mkdtempSync(join(tmpdir(), "houndsman-run-status-"));
55
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
51
56
  try {
52
- fn(dir);
57
+ const review = storage.getOrCreateReview({
58
+ repo: buildRepoIdentity(null, dir),
59
+ branch: "feature/x",
60
+ slug: "feature-x",
61
+ });
62
+ fn(storage, review.id);
53
63
  } finally {
64
+ storage.close();
54
65
  rmSync(dir, { recursive: true, force: true });
55
66
  }
56
67
  }
57
68
 
58
- describe("run-status — arquivo", () => {
69
+ describe("run-status — storage", () => {
59
70
  test("grava, lê e preserva/restaura o startedAt", () => {
60
- withTempDir((dir) => {
61
- writeRunStatus(dir, {
62
- branch: "feature/x",
71
+ withStorage((storage, reviewId) => {
72
+ writeRunStatus(storage, reviewId, {
63
73
  status: "reviewing",
64
74
  resetStartedAt: true,
65
75
  now: "2026-01-01T00:00:00.000Z",
66
76
  });
67
- const first = readRunStatus(dir);
77
+ const first = readRunStatus(storage, reviewId);
68
78
  expect(first?.status).toBe("reviewing");
69
79
  expect(first?.slug).toBe("feature-x");
70
80
  expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
71
81
 
72
- writeRunStatus(dir, {
73
- branch: "feature/x",
82
+ writeRunStatus(storage, reviewId, {
74
83
  status: "review_done",
75
84
  now: "2026-01-01T00:10:00.000Z",
76
85
  });
77
- const second = readRunStatus(dir);
86
+ const second = readRunStatus(storage, reviewId);
78
87
  expect(second?.status).toBe("review_done");
79
88
  expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
80
89
  expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
81
90
 
82
- writeRunStatus(dir, {
83
- branch: "feature/x",
91
+ writeRunStatus(storage, reviewId, {
84
92
  status: "reviewing",
85
93
  resetStartedAt: true,
86
94
  now: "2026-01-01T00:20:00.000Z",
87
95
  });
88
- expect(readRunStatus(dir)?.startedAt).toBe("2026-01-01T00:20:00.000Z");
96
+ expect(readRunStatus(storage, reviewId)?.startedAt).toBe(
97
+ "2026-01-01T00:20:00.000Z",
98
+ );
89
99
  });
90
100
  });
91
101
 
92
- test("readRunStatus devolve null sem arquivo", () => {
93
- withTempDir((dir) => {
94
- expect(readRunStatus(dir)).toBeNull();
102
+ test("readRunStatus devolve null para review inexistente", () => {
103
+ withStorage((storage) => {
104
+ expect(readRunStatus(storage, "missing")).toBeNull();
95
105
  });
96
106
  });
97
107
  });
@@ -127,7 +137,7 @@ describe("statusAfterFindingsChange", () => {
127
137
  expect(statusAfterFindingsChange("review_done", state)).toBe("fix_done");
128
138
  });
129
139
 
130
- test("sem run.json e sem fix → fallback review_done", () => {
140
+ test("sem review e sem fix → fallback review_done", () => {
131
141
  const state = stateOf([
132
142
  findingState({ decision: "pending", execState: "pending" }),
133
143
  ]);
@@ -136,12 +146,12 @@ describe("statusAfterFindingsChange", () => {
136
146
  });
137
147
 
138
148
  describe("pingFindingsStatus", () => {
139
- test("deriva do estado e grava o run.json", () => {
140
- withTempDir((dir) => {
149
+ test("deriva do estado e grava o status na review", () => {
150
+ withStorage((storage, reviewId) => {
141
151
  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");
152
+ const review = pingFindingsStatus(storage, reviewId, state);
153
+ expect(review.status).toBe("fixing");
154
+ expect(readRunStatus(storage, reviewId)?.status).toBe("fixing");
145
155
  });
146
156
  });
147
157
  });
@@ -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,50 +39,51 @@ 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
 
@@ -89,7 +91,7 @@ describe("server API", () => {
89
91
  test("GET /api/runs lista as runs com resumo", async () => {
90
92
  const env = makeEnv();
91
93
  try {
92
- const handler = createRequestHandler(env.config);
94
+ const handler = createRequestHandler(env.config, env);
93
95
  const res = await get(handler, "/api/runs");
94
96
  expect(res.status).toBe(200);
95
97
  const body = (await res.json()) as {
@@ -106,7 +108,7 @@ describe("server API", () => {
106
108
  test("GET findings reconstrói do artefato e PATCH atualiza o estado", async () => {
107
109
  const env = makeEnv();
108
110
  try {
109
- const handler = createRequestHandler(env.config);
111
+ const handler = createRequestHandler(env.config, env);
110
112
  const list = await get(handler, "/api/runs/main/findings?all=1");
111
113
  const body = (await list.json()) as {
112
114
  findings: { id: string; decision: string }[];
@@ -148,7 +150,7 @@ describe("server API", () => {
148
150
  test("PATCH expõe o run status em /api/runs", async () => {
149
151
  const env = makeEnv();
150
152
  try {
151
- const handler = createRequestHandler(env.config);
153
+ const handler = createRequestHandler(env.config, env);
152
154
  const before = (await (await get(handler, "/api/runs")).json()) as {
153
155
  runs: { status: string | null }[];
154
156
  };
@@ -178,7 +180,7 @@ describe("server API", () => {
178
180
  test("POST sync devolve o resultado da reancoragem", async () => {
179
181
  const env = makeEnv();
180
182
  try {
181
- const handler = createRequestHandler(env.config);
183
+ const handler = createRequestHandler(env.config, env);
182
184
  await get(handler, "/api/runs/main/findings?all=1");
183
185
  const res = await handler(
184
186
  new Request("http://localhost/api/runs/main/sync", { method: "POST" }),
@@ -195,6 +197,8 @@ describe("server API", () => {
195
197
  const env = makeEnv();
196
198
  try {
197
199
  const handler = createRequestHandler(env.config, {
200
+ storage: env.storage,
201
+ repo: env.repo,
198
202
  ghRunner: () => ({
199
203
  ok: true,
200
204
  code: 0,
@@ -224,8 +228,9 @@ describe("server API", () => {
224
228
  test("POST submit posta comentários e grava o vínculo no estado", async () => {
225
229
  const env = makeEnv();
226
230
  try {
227
- seedState(env.artifactDir, "main");
228
231
  const handler = createRequestHandler(env.config, {
232
+ storage: env.storage,
233
+ repo: env.repo,
229
234
  ghRunner: (args) =>
230
235
  args[0] === "pr"
231
236
  ? {
@@ -273,10 +278,35 @@ describe("server API", () => {
273
278
  }
274
279
  });
275
280
 
281
+ test("artifacts lista todos os artefatos da run (inclui briefs)", async () => {
282
+ const env = makeEnv();
283
+ try {
284
+ const handler = createRequestHandler(env.config, env);
285
+ const res = await get(handler, "/api/runs/main/artifacts");
286
+ expect(res.status).toBe(200);
287
+ const body = (await res.json()) as {
288
+ artifacts: { name: string; kind: string }[];
289
+ };
290
+ expect(body.artifacts.map((item) => item.name)).toEqual(
291
+ expect.arrayContaining([
292
+ "manifest.json",
293
+ "classification.json",
294
+ "review.md",
295
+ "briefs/alpha.md",
296
+ ]),
297
+ );
298
+ expect(
299
+ body.artifacts.find((item) => item.name === "briefs/alpha.md")?.kind,
300
+ ).toBe("brief");
301
+ } finally {
302
+ env.cleanup();
303
+ }
304
+ });
305
+
276
306
  test("artifacts serve arquivos da run e barra o resto", async () => {
277
307
  const env = makeEnv();
278
308
  try {
279
- const handler = createRequestHandler(env.config);
309
+ const handler = createRequestHandler(env.config, env);
280
310
  const ok = await get(handler, "/api/runs/main/artifacts/manifest.json");
281
311
  expect(ok.status).toBe(200);
282
312
  expect(await ok.json()).toEqual({ branch: "main" });
@@ -285,6 +315,10 @@ describe("server API", () => {
285
315
  expect(review.status).toBe(200);
286
316
  expect(await review.text()).toContain("# Review");
287
317
 
318
+ const brief = await get(handler, "/api/runs/main/artifacts/briefs/alpha.md");
319
+ expect(brief.status).toBe(200);
320
+ expect(await brief.text()).toContain("# Brief alpha");
321
+
288
322
  const denied = await get(handler, "/api/runs/main/artifacts/evil.txt");
289
323
  expect(denied.status).toBe(404);
290
324
 
@@ -295,23 +329,17 @@ describe("server API", () => {
295
329
  }
296
330
  });
297
331
 
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
- }
332
+ test("valida o slug usado nas rotas", () => {
333
+ expect(isSlug("main")).toBe(true);
334
+ expect(isSlug("feature-x")).toBe(true);
335
+ expect(isSlug("../main")).toBe(false);
336
+ expect(isSlug("a/b")).toBe(false);
309
337
  });
310
338
 
311
339
  test("GET / serve o shell e /assets/app.js empacota o cliente", async () => {
312
340
  const env = makeEnv();
313
341
  try {
314
- const handler = createRequestHandler(env.config);
342
+ const handler = createRequestHandler(env.config, env);
315
343
  const shell = await get(handler, "/");
316
344
  expect(await shell.text()).toContain('id="app"');
317
345
 
@@ -328,7 +356,7 @@ describe("server API", () => {
328
356
  test("serve sem cache (no-store) e busta o bundle com um build id", async () => {
329
357
  const env = makeEnv();
330
358
  try {
331
- const handler = createRequestHandler(env.config);
359
+ const handler = createRequestHandler(env.config, env);
332
360
  const shell = await get(handler, "/");
333
361
  expect(shell.headers.get("cache-control")).toContain("no-store");
334
362
  expect(await shell.text()).toContain("/assets/app.js?v=");
@@ -346,7 +374,12 @@ describe("startServer", () => {
346
374
  const env = makeEnv();
347
375
  let server: ReturnType<typeof startServer>["server"] | undefined;
348
376
  try {
349
- const started = startServer({ config: env.config, port: 0 });
377
+ const started = startServer({
378
+ config: env.config,
379
+ port: 0,
380
+ storage: env.storage,
381
+ repo: env.repo,
382
+ });
350
383
  server = started.server;
351
384
  const res = await fetch(`${started.url}/api/health`);
352
385
  expect(res.status).toBe(200);