@payconductor/houndsman 0.3.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.
- package/README.md +18 -6
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/help.ts +7 -3
- package/src/cli/index.ts +2 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +155 -120
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +32 -71
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +175 -128
- package/src/core/paths.ts +23 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +28 -53
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +269 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +160 -0
- package/src/core/storage/sqlite/schema.ts +108 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
- package/src/core/storage/types.ts +143 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/ArtifactsPanel.tsx +137 -23
- package/src/server/client/FindingCard.tsx +37 -6
- package/src/server/client/MarkdownText.tsx +101 -0
- package/src/server/client/SubmitModal.tsx +9 -4
- package/src/server/client/code-highlight.ts +422 -0
- package/src/server/client/constants.ts +130 -24
- package/src/server/client/json-highlight.ts +55 -0
- package/src/server/client/markdown.ts +153 -0
- package/src/server/client/types.ts +23 -0
- package/src/server/index.ts +95 -15
- package/src/server/run-repository.ts +144 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +27 -18
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +5 -5
- package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
- package/src/tests/baseline.test.ts +76 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +6 -3
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +89 -0
- package/src/tests/review-scripts.test.ts +8 -5
- package/src/tests/run-status.test.ts +33 -23
- package/src/tests/server.test.ts +108 -75
- package/src/tests/storage.test.ts +386 -0
|
@@ -9,10 +9,11 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import type { GitResult, GitRunner } from "../core/git-runner";
|
|
12
|
-
import { RunLayout } from "../core/run-layout";
|
|
13
12
|
import { GhClient, type GhRunner } from "../core/services/gh-client";
|
|
14
13
|
import { GitRepository } from "../core/services/git-repository";
|
|
15
14
|
import { ProjectFiles } from "../core/services/project-files";
|
|
15
|
+
import type { ReviewRecord } from "../core/storage/types";
|
|
16
|
+
import { ReviewWorkArea } from "../core/storage/work-area";
|
|
16
17
|
|
|
17
18
|
/** Runner roteado por `args.join(" ")`, registrando as chamadas. */
|
|
18
19
|
function fakeGit(routes: Record<string, GitResult>): {
|
|
@@ -163,48 +164,42 @@ describe("ProjectFiles", () => {
|
|
|
163
164
|
});
|
|
164
165
|
});
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
186
|
-
expect(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
expect(layout.findingFile("alpha").replace(/\\/g, "/")).toBe(
|
|
190
|
-
".houndsman/.runs/feature-x/findings/alpha.json",
|
|
167
|
+
/** Review fake para testar o layout do staging. */
|
|
168
|
+
function reviewFixture(): ReviewRecord {
|
|
169
|
+
return {
|
|
170
|
+
id: "review-uuid",
|
|
171
|
+
repoKey: "path:/tmp/repo",
|
|
172
|
+
repoRoot: "/tmp/repo",
|
|
173
|
+
branch: "feature/x",
|
|
174
|
+
slug: "feature-x",
|
|
175
|
+
status: null,
|
|
176
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
177
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe("ReviewWorkArea", () => {
|
|
182
|
+
test("centraliza os caminhos do staging (slug normalizado)", () => {
|
|
183
|
+
const work = new ReviewWorkArea(reviewFixture(), "./work");
|
|
184
|
+
const root = work.root.replace(/\\/g, "/");
|
|
185
|
+
expect(root).toBe("work/review-uuid");
|
|
186
|
+
expect(work.briefsDir.replace(/\\/g, "/")).toBe("work/review-uuid/briefs");
|
|
187
|
+
expect(work.findingsDir.replace(/\\/g, "/")).toBe("work/review-uuid/findings");
|
|
188
|
+
expect(work.findingFile("alpha").replace(/\\/g, "/")).toBe(
|
|
189
|
+
"work/review-uuid/findings/alpha.json",
|
|
191
190
|
);
|
|
192
|
-
expect(
|
|
193
|
-
"
|
|
191
|
+
expect(work.briefFile("alpha").replace(/\\/g, "/")).toBe(
|
|
192
|
+
"work/review-uuid/briefs/alpha.md",
|
|
194
193
|
);
|
|
195
194
|
});
|
|
196
195
|
|
|
197
|
-
test("contextPaths compartilha a pasta da
|
|
198
|
-
const paths = new
|
|
199
|
-
expect(paths.dir.replace(/\\/g, "/")).toBe("
|
|
200
|
-
expect(paths.context.replace(/\\/g, "/")).toBe(
|
|
201
|
-
".houndsman/.runs/main/context.json",
|
|
202
|
-
);
|
|
196
|
+
test("contextPaths compartilha a pasta da review", () => {
|
|
197
|
+
const paths = new ReviewWorkArea(reviewFixture(), "./work").contextPaths;
|
|
198
|
+
expect(paths.dir.replace(/\\/g, "/")).toBe("work/review-uuid");
|
|
199
|
+
expect(paths.context.replace(/\\/g, "/")).toBe("work/review-uuid/context.json");
|
|
203
200
|
expect(paths.summary.replace(/\\/g, "/")).toBe(
|
|
204
|
-
"
|
|
205
|
-
);
|
|
206
|
-
expect(paths.brief.replace(/\\/g, "/")).toBe(
|
|
207
|
-
".houndsman/.runs/main/context-brief.md",
|
|
201
|
+
"work/review-uuid/context-summary.json",
|
|
208
202
|
);
|
|
203
|
+
expect(paths.brief.replace(/\\/g, "/")).toBe("work/review-uuid/context-brief.md");
|
|
209
204
|
});
|
|
210
205
|
});
|
|
@@ -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
|
-
|
|
1155
|
-
"
|
|
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
|
-
/**
|
|
49
|
-
function
|
|
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
|
-
|
|
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 —
|
|
69
|
+
describe("run-status — storage", () => {
|
|
59
70
|
test("grava, lê e preserva/restaura o startedAt", () => {
|
|
60
|
-
|
|
61
|
-
writeRunStatus(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
93
|
-
|
|
94
|
-
expect(readRunStatus(
|
|
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
|
|
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
|
|
140
|
-
|
|
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
|
|
143
|
-
expect(
|
|
144
|
-
expect(readRunStatus(
|
|
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
|
});
|
package/src/tests/server.test.ts
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
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 {
|
|
12
|
+
import { isSlug } from "../server/artifacts";
|
|
8
13
|
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
9
14
|
|
|
10
|
-
/**
|
|
15
|
+
/** Ambiente fake: banco temporário + review `main` com um finding. */
|
|
11
16
|
function makeEnv(): {
|
|
12
|
-
|
|
17
|
+
storage: SQLiteStorageProvider;
|
|
18
|
+
repo: RepoIdentity;
|
|
19
|
+
reviewId: string;
|
|
13
20
|
config: HoundsmanConfig;
|
|
14
21
|
cleanup: () => void;
|
|
15
22
|
} {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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("
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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({
|
|
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);
|