@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.
- package/README.md +39 -78
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +89 -9
- package/src/core/assets.ts +8 -0
- package/src/core/brief.ts +25 -2
- package/src/core/commands/findings.ts +390 -0
- package/src/core/commands/scaffold.ts +20 -0
- 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 +538 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/orchestrator.ts +169 -17
- package/src/core/paths.ts +3 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +113 -9
- package/src/core/types.ts +10 -0
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +66 -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 +43 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +63 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +48 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +365 -0
- package/src/skill/README.md +14 -6
- package/src/skill/SETUP.md +5 -3
- package/src/skill/houndsman-update.md +1 -1
- package/src/skill/houndsman.md +87 -10
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +226 -0
- package/src/tests/findings-state.test.ts +274 -0
- package/src/tests/review-scripts.test.ts +299 -0
- 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 {
|
|
11
|
-
export {
|
|
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
|
+
}
|