@payconductor/houndsman 0.1.0 → 0.1.3
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 +68 -89
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +101 -10
- package/src/core/assets.ts +11 -0
- package/src/core/brief.ts +27 -5
- package/src/core/commands/findings.ts +618 -0
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +33 -44
- package/src/core/config.ts +42 -0
- package/src/core/diff-source.ts +140 -0
- package/src/core/file-classifier.ts +50 -2
- package/src/core/finding-anchor.ts +147 -0
- package/src/core/findings-state.ts +556 -0
- package/src/core/fix-batch.ts +236 -0
- package/src/core/git-revision.ts +105 -0
- package/src/core/jev/index.ts +1 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +362 -23
- package/src/core/paths.ts +9 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +118 -9
- package/src/core/run-status.ts +123 -0
- package/src/core/types.ts +10 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +67 -0
- package/src/server/client/App.tsx +158 -0
- package/src/server/client/ArtifactsPanel.tsx +48 -0
- package/src/server/client/Filters.tsx +83 -0
- package/src/server/client/FindingCard.tsx +122 -0
- package/src/server/client/FindingsList.tsx +54 -0
- package/src/server/client/RunList.tsx +52 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +94 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +52 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +376 -0
- package/src/skill/AUDIT-PROTOCOL.md +2 -2
- package/src/skill/README.md +43 -11
- package/src/skill/SETUP.md +16 -4
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +83 -33
- package/src/skill/{houndsman-update.md → train.md} +12 -12
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +476 -0
- package/src/tests/findings-state.test.ts +325 -0
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +242 -0
- package/src/tests/verify.test.ts +170 -0
|
@@ -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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar: logo do houndsman + lista de runs (branch e nº de findings).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describeRunStatus } from "./constants";
|
|
6
|
+
import type { RunSummary } from "./types";
|
|
7
|
+
|
|
8
|
+
export function RunList({
|
|
9
|
+
runs,
|
|
10
|
+
slug,
|
|
11
|
+
onSelect,
|
|
12
|
+
}: {
|
|
13
|
+
runs: RunSummary[];
|
|
14
|
+
slug: string | null;
|
|
15
|
+
onSelect: (slug: string) => void;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<aside class="sidebar">
|
|
19
|
+
<div class="brand">
|
|
20
|
+
<img class="logo" src="/assets/logo.png" alt="houndsman" />
|
|
21
|
+
<div>
|
|
22
|
+
<h1>Houndsman</h1>
|
|
23
|
+
<p class="sub">Revisões e triagem</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<nav class="runs">
|
|
27
|
+
{runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
|
|
28
|
+
{runs.map((run) => {
|
|
29
|
+
const status = describeRunStatus(run);
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
title={`Abrir a run da branch ${run.branch}`}
|
|
34
|
+
class={`run${run.slug === slug ? " active" : ""}`}
|
|
35
|
+
onClick={() => onSelect(run.slug)}
|
|
36
|
+
>
|
|
37
|
+
<span class="run-branch">{run.branch}</span>
|
|
38
|
+
<span class="run-meta">
|
|
39
|
+
{run.summary.present}/{run.summary.total} presentes
|
|
40
|
+
</span>
|
|
41
|
+
{status && (
|
|
42
|
+
<span class={`run-status status-${status.kind}`}>
|
|
43
|
+
{status.label}
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</nav>
|
|
50
|
+
</aside>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barra de resumo da triagem (total, presentes e contagens por decisão/execução).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DECISION_LABELS, DECISIONS } from "./constants";
|
|
6
|
+
import type { Summary } from "./types";
|
|
7
|
+
|
|
8
|
+
export function SummaryBar({ summary }: { summary: Summary }) {
|
|
9
|
+
return (
|
|
10
|
+
<div class="summary-bar">
|
|
11
|
+
<span>Total: {summary.total}</span>
|
|
12
|
+
<span>Presentes: {summary.present}</span>
|
|
13
|
+
{DECISIONS.filter((decision) => decision !== "pending").map((decision) => (
|
|
14
|
+
<span key={decision}>
|
|
15
|
+
{DECISION_LABELS[decision]}: {summary.byDecision[decision] ?? 0}
|
|
16
|
+
</span>
|
|
17
|
+
))}
|
|
18
|
+
<span>Feitos: {summary.byExecState.done ?? 0}</span>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers de acesso à API local do servidor.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DECISIONS, EXEC_STATES, SEVERITY_ORDER } from "./constants";
|
|
6
|
+
import type { Decision, ExecState, Finding, Severity, Summary } from "./types";
|
|
7
|
+
|
|
8
|
+
/** `fetch` JSON: lança com o corpo do erro quando a resposta não é 2xx. */
|
|
9
|
+
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|
10
|
+
const res = await fetch(path, init);
|
|
11
|
+
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
|
|
12
|
+
return (await res.json()) as T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Recalcula o resumo a partir dos findings atuais (contadores vivos na UI). */
|
|
16
|
+
export function computeSummary(findings: Finding[]): Summary {
|
|
17
|
+
const byDecision = Object.fromEntries(DECISIONS.map((d) => [d, 0])) as Record<
|
|
18
|
+
Decision,
|
|
19
|
+
number
|
|
20
|
+
>;
|
|
21
|
+
const byExecState = Object.fromEntries(EXEC_STATES.map((e) => [e, 0])) as Record<
|
|
22
|
+
ExecState,
|
|
23
|
+
number
|
|
24
|
+
>;
|
|
25
|
+
const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0])) as Record<
|
|
26
|
+
Severity,
|
|
27
|
+
number
|
|
28
|
+
>;
|
|
29
|
+
let present = 0;
|
|
30
|
+
for (const finding of findings) {
|
|
31
|
+
if (finding.present) present++;
|
|
32
|
+
byDecision[finding.decision]++;
|
|
33
|
+
byExecState[finding.execState]++;
|
|
34
|
+
bySeverity[finding.severity]++;
|
|
35
|
+
}
|
|
36
|
+
return { total: findings.length, present, byDecision, byExecState, bySeverity };
|
|
37
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constantes de apresentação do cliente (labels, ordem, emojis).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Decision, ExecState, RunStatus, RunSummary, Severity } from "./types";
|
|
6
|
+
|
|
7
|
+
export const SEVERITY_EMOJI: Record<Severity, string> = {
|
|
8
|
+
blocking: "❌",
|
|
9
|
+
attention: "⚠️",
|
|
10
|
+
suggestion: "💡",
|
|
11
|
+
nit: "🔎",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const SEVERITY_ORDER: Severity[] = ["blocking", "attention", "suggestion", "nit"];
|
|
15
|
+
|
|
16
|
+
export const DECISIONS: Decision[] = ["pending", "execute", "ignore"];
|
|
17
|
+
|
|
18
|
+
export const DECISION_LABELS: Record<Decision, string> = {
|
|
19
|
+
pending: "Pendente",
|
|
20
|
+
execute: "Executar",
|
|
21
|
+
ignore: "Ignorar",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const EXEC_STATES: ExecState[] = ["pending", "in_progress", "done", "stale"];
|
|
25
|
+
|
|
26
|
+
export const EXEC_LABELS: Record<ExecState, string> = {
|
|
27
|
+
pending: "exec: pendente",
|
|
28
|
+
in_progress: "exec: em andamento",
|
|
29
|
+
done: "exec: feito",
|
|
30
|
+
stale: "exec: stale",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const RUN_STATUS_LABELS: Record<RunStatus, string> = {
|
|
34
|
+
reviewing: "Revisando",
|
|
35
|
+
review_done: "Revisão pronta",
|
|
36
|
+
fixing: "Corrigindo",
|
|
37
|
+
fix_done: "Correção pronta",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Limiar de inatividade para exibir timeout (display-only; não é status). */
|
|
41
|
+
export const STATUS_TIMEOUT_MS = 10 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
export type RunStatusView = { kind: string; label: string; timeout: boolean };
|
|
44
|
+
|
|
45
|
+
/** View do status de uma run — label; vira timeout quando ativo e sem ping recente. */
|
|
46
|
+
export function describeRunStatus(
|
|
47
|
+
run: RunSummary,
|
|
48
|
+
now: number = Date.now(),
|
|
49
|
+
): RunStatusView | null {
|
|
50
|
+
const status = run.status;
|
|
51
|
+
if (!status) return null;
|
|
52
|
+
const active = status === "reviewing" || status === "fixing";
|
|
53
|
+
const updated = run.updatedAt ? Date.parse(run.updatedAt) : Number.NaN;
|
|
54
|
+
if (active && Number.isFinite(updated) && now - updated > STATUS_TIMEOUT_MS) {
|
|
55
|
+
return {
|
|
56
|
+
kind: "timeout",
|
|
57
|
+
label: status === "reviewing" ? "Timeout na revisão" : "Timeout na correção",
|
|
58
|
+
timeout: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { kind: status, label: RUN_STATUS_LABELS[status], timeout: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Artefatos que podem ser lidos na aba "Artefatos". */
|
|
65
|
+
export const ARTIFACTS = [
|
|
66
|
+
"manifest.json",
|
|
67
|
+
"classification.json",
|
|
68
|
+
"candidates.json",
|
|
69
|
+
"context-summary.json",
|
|
70
|
+
"review.md",
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
export type ArtifactName = (typeof ARTIFACTS)[number];
|
|
74
|
+
|
|
75
|
+
/** Descrição de cada artefato (mostrada ao selecioná-lo na aba). */
|
|
76
|
+
export const ARTIFACT_INFO: Record<ArtifactName, string> = {
|
|
77
|
+
"manifest.json":
|
|
78
|
+
"Resumo da execução: watchdogs ativos, arquivos, orçamento, churn, Jev e contexto da PR.",
|
|
79
|
+
"classification.json":
|
|
80
|
+
"Classificação do diff: watchdogs ativados por arquivo, status (A/M/D/R) e churn por arquivo.",
|
|
81
|
+
"candidates.json":
|
|
82
|
+
"Indícios da pré-análise Jev (regra + arquivo + confiança) — não são achados.",
|
|
83
|
+
"context-summary.json":
|
|
84
|
+
"Resumo da PR (objetivo, motivação e alterações) gerado no Passo 0.",
|
|
85
|
+
"review.md": "Relatório consolidado da revisão — o entregável.",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Legenda única de cada aba (explica o que a aba é; mostrada quando selecionada). */
|
|
89
|
+
export const TAB_CAPTIONS = {
|
|
90
|
+
findings:
|
|
91
|
+
"Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
|
|
92
|
+
artifacts:
|
|
93
|
+
"Artefatos brutos da run: manifesto, classificação, candidatos Jev, contexto da PR e o relatório consolidado (review.md).",
|
|
94
|
+
} as const;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry do cliente (Preact): monta o `<App />` no `#app`.
|
|
3
|
+
*
|
|
4
|
+
* Bundle gerado em memória por `Bun.build` (`client-build.ts`). Os componentes
|
|
5
|
+
* vivem em arquivos irmãos nesta pasta (`App.tsx`, `RunList.tsx`, ...).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { render } from "preact";
|
|
9
|
+
import { App } from "./App";
|
|
10
|
+
|
|
11
|
+
const root = document.getElementById("app");
|
|
12
|
+
if (root) render(<App />, root);
|