@payconductor/houndsman 0.1.0 → 0.1.1

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 (45) hide show
  1. package/README.md +39 -78
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +89 -9
  6. package/src/core/assets.ts +8 -0
  7. package/src/core/brief.ts +25 -2
  8. package/src/core/commands/findings.ts +390 -0
  9. package/src/core/commands/scaffold.ts +20 -0
  10. package/src/core/config.ts +42 -0
  11. package/src/core/diff-source.ts +140 -0
  12. package/src/core/file-classifier.ts +50 -2
  13. package/src/core/finding-anchor.ts +147 -0
  14. package/src/core/findings-state.ts +538 -0
  15. package/src/core/jev/jev-client.ts +72 -11
  16. package/src/core/jev/jev-detector.ts +21 -19
  17. package/src/core/orchestrator.ts +169 -17
  18. package/src/core/paths.ts +3 -0
  19. package/src/core/registry-check.ts +15 -0
  20. package/src/core/report-builder.ts +113 -9
  21. package/src/core/types.ts +10 -0
  22. package/src/index.ts +78 -2
  23. package/src/server/artifacts.ts +66 -0
  24. package/src/server/client/App.tsx +158 -0
  25. package/src/server/client/ArtifactsPanel.tsx +48 -0
  26. package/src/server/client/Filters.tsx +83 -0
  27. package/src/server/client/FindingCard.tsx +122 -0
  28. package/src/server/client/FindingsList.tsx +54 -0
  29. package/src/server/client/RunList.tsx +43 -0
  30. package/src/server/client/SummaryBar.tsx +21 -0
  31. package/src/server/client/api.ts +37 -0
  32. package/src/server/client/constants.ts +63 -0
  33. package/src/server/client/index.tsx +12 -0
  34. package/src/server/client/types.ts +48 -0
  35. package/src/server/client-build.ts +47 -0
  36. package/src/server/index.ts +365 -0
  37. package/src/skill/README.md +14 -6
  38. package/src/skill/SETUP.md +5 -3
  39. package/src/skill/houndsman-update.md +1 -1
  40. package/src/skill/houndsman.md +87 -10
  41. package/src/tests/findings-anchor.test.ts +120 -0
  42. package/src/tests/findings-commands.test.ts +226 -0
  43. package/src/tests/findings-state.test.ts +274 -0
  44. package/src/tests/review-scripts.test.ts +299 -0
  45. package/src/tests/server.test.ts +212 -0
package/src/index.ts CHANGED
@@ -7,8 +7,22 @@
7
7
 
8
8
  export { assetPath, OUTPUT_FORMAT_FILE } from "./core/assets";
9
9
  export { defaultRenderBrief, PROCEDURE, renderBrief } from "./core/brief";
10
- export type { HoundsmanConfig, HoundsmanConfigInput } from "./core/config";
11
- export { DEFAULT_ALWAYS_INCLUDE_DOCS, defineHoundsmanConfig } from "./core/config";
10
+ export type { FindingsCommandResult } from "./core/commands/findings";
11
+ export { runFindingsCommand } from "./core/commands/findings";
12
+ export type {
13
+ HoundsmanBudgets,
14
+ HoundsmanConcurrency,
15
+ HoundsmanConfig,
16
+ HoundsmanConfigInput,
17
+ } from "./core/config";
18
+ export {
19
+ DEFAULT_ALWAYS_INCLUDE_DOCS,
20
+ DEFAULT_JEV_CONCURRENCY,
21
+ DEFAULT_MAX_FILES_PER_BRIEF,
22
+ defineHoundsmanConfig,
23
+ resolveJevConcurrency,
24
+ resolveMaxFilesPerBrief,
25
+ } from "./core/config";
12
26
  export type { LoadedConfig } from "./core/config-loader";
13
27
  export {
14
28
  CONFIG_FILE_CANDIDATES,
@@ -16,12 +30,67 @@ export {
16
30
  resolveConfigPath,
17
31
  } from "./core/config-loader";
18
32
  export {
33
+ collectChurn,
34
+ collectDiff,
35
+ type DiffSource,
36
+ parseDiffIndex,
37
+ parseNumstat,
38
+ } from "./core/diff-source";
39
+ export {
40
+ churnOf,
19
41
  classifyEntries,
20
42
  classifyRange,
21
43
  isExcludedPath,
22
44
  matchWatchdogs,
23
45
  parseEntries,
46
+ selectByBudget,
47
+ sortByRisk,
24
48
  } from "./core/file-classifier";
49
+ export type {
50
+ AnchorLocation,
51
+ FindingAnchor,
52
+ ReanchorStatus,
53
+ } from "./core/finding-anchor";
54
+ export {
55
+ buildAnchor,
56
+ fingerprintOf,
57
+ hashKey,
58
+ locateAnchor,
59
+ normalizeLine,
60
+ reanchor,
61
+ } from "./core/finding-anchor";
62
+ export type {
63
+ BuildStateOptions,
64
+ FileReader,
65
+ FindingDecision,
66
+ FindingExecState,
67
+ FindingHistoryEntry,
68
+ FindingState,
69
+ FindingsFilter,
70
+ FindingsStateFile,
71
+ FindingsSummary,
72
+ FindingUpdate,
73
+ SyncResult,
74
+ } from "./core/findings-state";
75
+ export {
76
+ applyFindingUpdate,
77
+ buildStateFromFindings,
78
+ buildStateFromRunDir,
79
+ compareFindings,
80
+ emptyState,
81
+ FINDING_DECISIONS,
82
+ FINDING_EXEC_STATES,
83
+ FINDINGS_STATE_VERSION,
84
+ FindingsStateStore,
85
+ fileReader,
86
+ filterFindings,
87
+ newFindingId,
88
+ reorder,
89
+ runDir,
90
+ stateFile,
91
+ summarize,
92
+ syncState,
93
+ } from "./core/findings-state";
25
94
  export {
26
95
  ARTIFACT_DIR,
27
96
  CORE_DIR,
@@ -29,10 +98,15 @@ export {
29
98
  OUTPUT_FORMAT_PATH,
30
99
  PROJECT_DIR,
31
100
  relFromRoot,
101
+ STATE_FILE,
32
102
  } from "./core/paths";
33
103
  export { checkRegistry } from "./core/registry-check";
104
+ export type { DomainReport } from "./core/report-builder";
105
+ export { renderDomainReport } from "./core/report-builder";
34
106
  export type {
107
+ ChurnMap,
35
108
  ClassifyRule,
109
+ FileChurn,
36
110
  FileStatus,
37
111
  FileStatusMap,
38
112
  Rule,
@@ -54,3 +128,5 @@ export {
54
128
  getWatchdog,
55
129
  getWatchdogContext,
56
130
  } from "./core/watchdog-registry";
131
+ export type { ServerOptions } from "./server";
132
+ export { createRequestHandler, startServer } from "./server";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Descoberta de runs e leitura segura de artefatos (servidor).
3
+ *
4
+ * Toda leitura é restrita à pasta da run — `readRunFile` barra path traversal.
5
+ */
6
+
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { isAbsolute, join, relative, resolve } from "node:path";
9
+
10
+ /** Arquivos que marcam uma pasta como "run" do houndsman. */
11
+ const RUN_MARKERS = [
12
+ "manifest.json",
13
+ "state.json",
14
+ "classification.json",
15
+ "review.md",
16
+ ] as const;
17
+
18
+ /** Slugs (nomes de pasta) das runs sob o `artifactDir`. */
19
+ export function listRunSlugs(artifactDir: string): string[] {
20
+ if (!existsSync(artifactDir)) return [];
21
+ try {
22
+ return readdirSync(artifactDir, { withFileTypes: true })
23
+ .filter((entry) => entry.isDirectory())
24
+ .map((entry) => entry.name)
25
+ .filter((slug) =>
26
+ RUN_MARKERS.some((marker) => existsSync(join(artifactDir, slug, marker))),
27
+ )
28
+ .sort();
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /** Diretório absoluto de uma run; null se o slug for inválido/inexistente. */
35
+ export function runDirForSlug(artifactDir: string, slug: string): string | null {
36
+ if (!/^[a-zA-Z0-9._-]+$/.test(slug)) return null;
37
+ const dir = join(artifactDir, slug);
38
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return null;
39
+ return dir;
40
+ }
41
+
42
+ /** Lê um arquivo DENTRO da run; null se ausente ou fora do diretório. */
43
+ export function readRunFile(runDir: string, name: string): string | null {
44
+ const root = resolve(runDir);
45
+ const target = resolve(root, name);
46
+ const rel = relative(root, target);
47
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
48
+ try {
49
+ return existsSync(target) && statSync(target).isFile()
50
+ ? readFileSync(target, "utf8")
51
+ : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Lê e faz parse de um JSON da run (null se ausente/inválido). */
58
+ export function readRunJson<T>(runDir: string, name: string): T | null {
59
+ const raw = readRunFile(runDir, name);
60
+ if (raw === null) return null;
61
+ try {
62
+ return JSON.parse(raw) as T;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * App: layout do servidor — sidebar de runs, abas (Findings/Artefatos), legenda
3
+ * da aba ativa e os painéis. Concentra o estado e as chamadas à API.
4
+ */
5
+
6
+ import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
7
+ import { ArtifactsPanel } from "./ArtifactsPanel";
8
+ import { api, computeSummary } from "./api";
9
+ import { TAB_CAPTIONS } from "./constants";
10
+ import { Filters, type FiltersState } from "./Filters";
11
+ import { FindingsList } from "./FindingsList";
12
+ import { RunList } from "./RunList";
13
+ import { SummaryBar } from "./SummaryBar";
14
+ import type { Finding, FindingsResponse, RunSummary } from "./types";
15
+
16
+ type Tab = "findings" | "artifacts";
17
+
18
+ export function App() {
19
+ const [runs, setRuns] = useState<RunSummary[]>([]);
20
+ const [slug, setSlug] = useState<string | null>(null);
21
+ const [data, setData] = useState<FindingsResponse | null>(null);
22
+ const [tab, setTab] = useState<Tab>("findings");
23
+ const [filters, setFilters] = useState<FiltersState>({
24
+ query: "",
25
+ decision: "all",
26
+ severity: "all",
27
+ showAbsent: false,
28
+ });
29
+ const [error, setError] = useState("");
30
+
31
+ const reloadRuns = useCallback(async () => {
32
+ try {
33
+ const { runs: list } = await api<{ runs: RunSummary[] }>("/api/runs");
34
+ setRuns(list);
35
+ setSlug((current) => current ?? list[0]?.slug ?? null);
36
+ } catch (err) {
37
+ setError(String(err));
38
+ }
39
+ }, []);
40
+
41
+ const loadFindings = useCallback(async (target: string) => {
42
+ try {
43
+ setData(await api<FindingsResponse>(`/api/runs/${target}/findings?all=1`));
44
+ setError("");
45
+ } catch (err) {
46
+ setError(String(err));
47
+ }
48
+ }, []);
49
+
50
+ useEffect(() => {
51
+ reloadRuns();
52
+ }, [reloadRuns]);
53
+
54
+ useEffect(() => {
55
+ if (slug) loadFindings(slug);
56
+ }, [slug, loadFindings]);
57
+
58
+ const onUpdated = useCallback(
59
+ (updated: Finding) => {
60
+ setData((current) =>
61
+ current
62
+ ? {
63
+ ...current,
64
+ findings: current.findings.map((finding) =>
65
+ finding.id === updated.id ? updated : finding,
66
+ ),
67
+ }
68
+ : current,
69
+ );
70
+ reloadRuns();
71
+ },
72
+ [reloadRuns],
73
+ );
74
+
75
+ const sync = useCallback(async () => {
76
+ if (!slug) return;
77
+ await api(`/api/runs/${slug}/sync`, { method: "POST" });
78
+ await loadFindings(slug);
79
+ await reloadRuns();
80
+ }, [slug, loadFindings, reloadRuns]);
81
+
82
+ const findings = useMemo(() => {
83
+ const needle = filters.query.trim().toLowerCase();
84
+ return (data?.findings ?? [])
85
+ .filter((finding) => filters.showAbsent || finding.present)
86
+ .filter(
87
+ (finding) =>
88
+ filters.decision === "all" || finding.decision === filters.decision,
89
+ )
90
+ .filter(
91
+ (finding) =>
92
+ filters.severity === "all" || finding.severity === filters.severity,
93
+ )
94
+ .filter(
95
+ (finding) =>
96
+ !needle ||
97
+ finding.file.toLowerCase().includes(needle) ||
98
+ finding.rule.toLowerCase().includes(needle) ||
99
+ finding.message.toLowerCase().includes(needle),
100
+ );
101
+ }, [data, filters]);
102
+
103
+ const summary = useMemo(() => computeSummary(data?.findings ?? []), [data]);
104
+
105
+ return (
106
+ <div class="app">
107
+ <RunList runs={runs} slug={slug} onSelect={setSlug} />
108
+ <main class="main">
109
+ <header class="topbar">
110
+ <div class="tabs">
111
+ <button
112
+ type="button"
113
+ title="Achados da revisão: triagem e decisões por finding"
114
+ class={`btn tab${tab === "findings" ? " active" : ""}`}
115
+ onClick={() => setTab("findings")}
116
+ >
117
+ Findings
118
+ </button>
119
+ <button
120
+ type="button"
121
+ title="Arquivos brutos gerados pela revisão (manifesto, classificação, relatório…)"
122
+ class={`btn tab${tab === "artifacts" ? " active" : ""}`}
123
+ onClick={() => setTab("artifacts")}
124
+ >
125
+ Artefatos
126
+ </button>
127
+ </div>
128
+ {slug && (
129
+ <button
130
+ type="button"
131
+ title="Reancorar: recalcula a linha de cada finding lendo o arquivo atual — use após um auto-fix que deslocou as linhas"
132
+ class="btn sync"
133
+ onClick={sync}
134
+ >
135
+ ↻ Reancorar (sync)
136
+ </button>
137
+ )}
138
+ </header>
139
+
140
+ <p class="caption">{TAB_CAPTIONS[tab]}</p>
141
+ {error && <p class="error">{error}</p>}
142
+
143
+ {slug && tab === "findings" && data && (
144
+ <>
145
+ <SummaryBar summary={summary} />
146
+ <Filters value={filters} onChange={setFilters} />
147
+ <FindingsList
148
+ slug={slug}
149
+ findings={findings}
150
+ onUpdated={onUpdated}
151
+ />
152
+ </>
153
+ )}
154
+ {slug && tab === "artifacts" && <ArtifactsPanel slug={slug} />}
155
+ </main>
156
+ </div>
157
+ );
158
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Painel de artefatos: abas para ver o conteúdo bruto dos arquivos da run.
3
+ */
4
+
5
+ import { useEffect, useState } from "preact/hooks";
6
+ import { ARTIFACT_INFO, ARTIFACTS, type ArtifactName } from "./constants";
7
+
8
+ export function ArtifactsPanel({ slug }: { slug: string }) {
9
+ const [name, setName] = useState<ArtifactName>(ARTIFACTS[0]);
10
+ const [content, setContent] = useState("");
11
+ const [error, setError] = useState("");
12
+
13
+ useEffect(() => {
14
+ setError("");
15
+ fetch(`/api/runs/${slug}/artifacts/${name}`)
16
+ .then(async (res) => {
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
18
+ setContent(await res.text());
19
+ })
20
+ .catch((err) => {
21
+ setContent("");
22
+ setError(String(err));
23
+ });
24
+ }, [slug, name]);
25
+
26
+ return (
27
+ <div class="artifacts">
28
+ <div class="tabs">
29
+ {ARTIFACTS.map((artifact) => (
30
+ <button
31
+ type="button"
32
+ title={ARTIFACT_INFO[artifact]}
33
+ class={`btn tab${artifact === name ? " active" : ""}`}
34
+ onClick={() => setName(artifact)}
35
+ >
36
+ {artifact}
37
+ </button>
38
+ ))}
39
+ </div>
40
+ <p class="artifact-desc">{ARTIFACT_INFO[name]}</p>
41
+ {error ? (
42
+ <p class="error">{error}</p>
43
+ ) : (
44
+ <pre class="artifact-view">{content}</pre>
45
+ )}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Barra de filtros dos findings: busca, decisão, severidade e "mostrar ausentes".
3
+ */
4
+
5
+ import { DECISION_LABELS, DECISIONS, SEVERITY_ORDER } from "./constants";
6
+ import type { Decision, Severity } from "./types";
7
+
8
+ export type FiltersState = {
9
+ query: string;
10
+ decision: "all" | Decision;
11
+ severity: "all" | Severity;
12
+ showAbsent: boolean;
13
+ };
14
+
15
+ export function Filters({
16
+ value,
17
+ onChange,
18
+ }: {
19
+ value: FiltersState;
20
+ onChange: (next: FiltersState) => void;
21
+ }) {
22
+ const patch = (part: Partial<FiltersState>) => onChange({ ...value, ...part });
23
+
24
+ return (
25
+ <div class="filters">
26
+ <input
27
+ title="Filtrar por arquivo, regra ou mensagem"
28
+ placeholder="Buscar arquivo, regra ou mensagem…"
29
+ value={value.query}
30
+ onInput={(event) =>
31
+ patch({ query: (event.target as HTMLInputElement).value })
32
+ }
33
+ />
34
+ <select
35
+ title="Filtrar por decisão de triagem"
36
+ value={value.decision}
37
+ onChange={(event) =>
38
+ patch({
39
+ decision: (event.target as HTMLSelectElement).value as
40
+ | "all"
41
+ | Decision,
42
+ })
43
+ }
44
+ >
45
+ <option value="all">Todas as decisões</option>
46
+ {DECISIONS.map((decision) => (
47
+ <option value={decision}>{DECISION_LABELS[decision]}</option>
48
+ ))}
49
+ </select>
50
+ <select
51
+ title="Filtrar por severidade"
52
+ value={value.severity}
53
+ onChange={(event) =>
54
+ patch({
55
+ severity: (event.target as HTMLSelectElement).value as
56
+ | "all"
57
+ | Severity,
58
+ })
59
+ }
60
+ >
61
+ <option value="all">Todas as severidades</option>
62
+ {SEVERITY_ORDER.map((severity) => (
63
+ <option value={severity}>{severity}</option>
64
+ ))}
65
+ </select>
66
+ <label
67
+ class="toggle"
68
+ title="Incluir findings que sumiram do último relatório"
69
+ >
70
+ <input
71
+ type="checkbox"
72
+ checked={value.showAbsent}
73
+ onChange={(event) =>
74
+ patch({
75
+ showAbsent: (event.target as HTMLInputElement).checked,
76
+ })
77
+ }
78
+ />
79
+ mostrar ausentes
80
+ </label>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Cartão de um finding: dados + decisão (Executar/Ignorar), comentário e "feito".
3
+ */
4
+
5
+ import { useCallback, useEffect, useState } from "preact/hooks";
6
+ import { api } from "./api";
7
+ import { DECISION_LABELS, DECISIONS, EXEC_LABELS, SEVERITY_EMOJI } from "./constants";
8
+ import type { Finding } from "./types";
9
+
10
+ export function FindingCard({
11
+ slug,
12
+ finding,
13
+ onUpdated,
14
+ }: {
15
+ slug: string;
16
+ finding: Finding;
17
+ onUpdated: (finding: Finding) => void;
18
+ }) {
19
+ const [comment, setComment] = useState(finding.comment ?? "");
20
+ const [busy, setBusy] = useState(false);
21
+
22
+ useEffect(() => {
23
+ setComment(finding.comment ?? "");
24
+ }, [finding.comment]);
25
+
26
+ const patch = useCallback(
27
+ async (body: Record<string, unknown>) => {
28
+ setBusy(true);
29
+ try {
30
+ const { finding: updated } = await api<{ finding: Finding }>(
31
+ `/api/runs/${slug}/findings/${finding.id}`,
32
+ {
33
+ method: "PATCH",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify(body),
36
+ },
37
+ );
38
+ onUpdated(updated);
39
+ } finally {
40
+ setBusy(false);
41
+ }
42
+ },
43
+ [finding.id, onUpdated, slug],
44
+ );
45
+
46
+ const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
47
+ const count = finding.count > 1 ? `×${finding.count}` : "";
48
+ const isDone = finding.execState === "done";
49
+
50
+ return (
51
+ <article
52
+ class={`card sev-${finding.severity}${finding.present ? "" : " absent"}`}
53
+ >
54
+ <header class="card-head">
55
+ <span class="sev">
56
+ {SEVERITY_EMOJI[finding.severity]} {finding.severity}
57
+ </span>
58
+ <code class="loc">{location}</code>
59
+ {count && <span class="count">{count}</span>}
60
+ <span class="spacer" />
61
+ <code class="id" title="id do finding">
62
+ {finding.id}
63
+ </code>
64
+ </header>
65
+ <h3 class="rule">{finding.rule}</h3>
66
+ <p class="msg">{finding.message}</p>
67
+ <p class="action">
68
+ <strong>Ação:</strong> {finding.action}
69
+ </p>
70
+ {finding.evidence && <pre class="evidence">{finding.evidence}</pre>}
71
+ <footer class="card-foot">
72
+ <span class="watchdogs">{finding.watchdogIds.join(", ") || "—"}</span>
73
+ <span class="exec-state">{EXEC_LABELS[finding.execState]}</span>
74
+ </footer>
75
+ <div class="decisions">
76
+ {DECISIONS.map((decision) => (
77
+ <button
78
+ type="button"
79
+ title={`Marcar como ${DECISION_LABELS[decision]}`}
80
+ class={`btn dec-${decision}${finding.decision === decision ? " active" : ""}`}
81
+ disabled={busy}
82
+ onClick={() =>
83
+ patch({ decision, comment: comment.trim() || undefined })
84
+ }
85
+ >
86
+ {DECISION_LABELS[decision]}
87
+ </button>
88
+ ))}
89
+ </div>
90
+ <div class="comment-row">
91
+ <textarea
92
+ title="Comentário ou instrução adicional para o auto-fix"
93
+ placeholder="Comentário / instrução para o auto-fix (opcional)"
94
+ value={comment}
95
+ onInput={(event) =>
96
+ setComment((event.target as HTMLTextAreaElement).value)
97
+ }
98
+ />
99
+ <div class="comment-actions">
100
+ <button
101
+ type="button"
102
+ title="Salvar o comentário neste finding"
103
+ class="btn"
104
+ disabled={busy}
105
+ onClick={() => patch({ comment: comment.trim() })}
106
+ >
107
+ Salvar comentário
108
+ </button>
109
+ <button
110
+ type="button"
111
+ title="Alternar o estado de execução deste finding (feito/pendente)"
112
+ class={`btn done${isDone ? " active" : ""}`}
113
+ disabled={busy}
114
+ onClick={() => patch({ execState: isDone ? "pending" : "done" })}
115
+ >
116
+ {isDone ? "✓ Feito" : "Marcar feito"}
117
+ </button>
118
+ </div>
119
+ </div>
120
+ </article>
121
+ );
122
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Lista de findings agrupada por severidade (❌ > ⚠️ > 💡 > 🔎).
3
+ */
4
+
5
+ import { useMemo } from "preact/hooks";
6
+ import { SEVERITY_EMOJI, SEVERITY_ORDER } from "./constants";
7
+ import { FindingCard } from "./FindingCard";
8
+ import type { Finding, Severity } from "./types";
9
+
10
+ export function FindingsList({
11
+ slug,
12
+ findings,
13
+ onUpdated,
14
+ }: {
15
+ slug: string;
16
+ findings: Finding[];
17
+ onUpdated: (finding: Finding) => void;
18
+ }) {
19
+ const grouped = useMemo(() => {
20
+ const bySeverity = new Map<Severity, Finding[]>();
21
+ for (const severity of SEVERITY_ORDER) bySeverity.set(severity, []);
22
+ for (const finding of findings) bySeverity.get(finding.severity)?.push(finding);
23
+ return bySeverity;
24
+ }, [findings]);
25
+
26
+ if (findings.length === 0) {
27
+ return <p class="empty">Nenhum finding para os filtros.</p>;
28
+ }
29
+
30
+ return (
31
+ <>
32
+ {SEVERITY_ORDER.map((severity) => {
33
+ const items = grouped.get(severity) ?? [];
34
+ if (items.length === 0) return null;
35
+ return (
36
+ <section class="sev-group">
37
+ <h2>
38
+ {SEVERITY_EMOJI[severity]} {severity}{" "}
39
+ <span class="n">({items.length})</span>
40
+ </h2>
41
+ {items.map((finding) => (
42
+ <FindingCard
43
+ key={finding.id}
44
+ slug={slug}
45
+ finding={finding}
46
+ onUpdated={onUpdated}
47
+ />
48
+ ))}
49
+ </section>
50
+ );
51
+ })}
52
+ </>
53
+ );
54
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sidebar: logo do houndsman + lista de runs (branch e nº de findings).
3
+ */
4
+
5
+ import type { RunSummary } from "./types";
6
+
7
+ export function RunList({
8
+ runs,
9
+ slug,
10
+ onSelect,
11
+ }: {
12
+ runs: RunSummary[];
13
+ slug: string | null;
14
+ onSelect: (slug: string) => void;
15
+ }) {
16
+ return (
17
+ <aside class="sidebar">
18
+ <div class="brand">
19
+ <img class="logo" src="/assets/logo.png" alt="houndsman" />
20
+ <div>
21
+ <h1>Houndsman</h1>
22
+ <p class="sub">Revisões e triagem</p>
23
+ </div>
24
+ </div>
25
+ <nav class="runs">
26
+ {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
27
+ {runs.map((run) => (
28
+ <button
29
+ type="button"
30
+ title={`Abrir a run da branch ${run.branch}`}
31
+ class={`run${run.slug === slug ? " active" : ""}`}
32
+ onClick={() => onSelect(run.slug)}
33
+ >
34
+ <span class="run-branch">{run.branch}</span>
35
+ <span class="run-meta">
36
+ {run.summary.present}/{run.summary.total} presentes
37
+ </span>
38
+ </button>
39
+ ))}
40
+ </nav>
41
+ </aside>
42
+ );
43
+ }