@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.
Files changed (69) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/help.ts +7 -3
  8. package/src/cli/index.ts +2 -2
  9. package/src/core/commands/context.ts +29 -13
  10. package/src/core/commands/findings.ts +155 -120
  11. package/src/core/commands/review.ts +7 -8
  12. package/src/core/commands/scaffold.ts +15 -16
  13. package/src/core/config.ts +16 -7
  14. package/src/core/engine/houndsman-engine.ts +15 -1
  15. package/src/core/engine/review-session.ts +37 -9
  16. package/src/core/findings-state.ts +32 -71
  17. package/src/core/jev/jev-candidates.ts +49 -35
  18. package/src/core/orchestrator.ts +175 -128
  19. package/src/core/paths.ts +23 -17
  20. package/src/core/pr-context.ts +19 -5
  21. package/src/core/project-config.ts +4 -5
  22. package/src/core/renderers/manifest-writer.ts +29 -18
  23. package/src/core/run-layout.ts +4 -104
  24. package/src/core/run-status.ts +28 -53
  25. package/src/core/services/git-repository.ts +16 -0
  26. package/src/core/services/project-files.ts +13 -1
  27. package/src/core/storage/factory.ts +24 -0
  28. package/src/core/storage/file/file-storage.ts +269 -0
  29. package/src/core/storage/paths.ts +64 -0
  30. package/src/core/storage/repo-identity.ts +70 -0
  31. package/src/core/storage/sqlite/mappers.ts +160 -0
  32. package/src/core/storage/sqlite/schema.ts +108 -0
  33. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  34. package/src/core/storage/types.ts +143 -0
  35. package/src/core/storage/work-area.ts +191 -0
  36. package/src/index.ts +27 -6
  37. package/src/server/artifacts.ts +8 -61
  38. package/src/server/client/ArtifactsPanel.tsx +137 -23
  39. package/src/server/client/FindingCard.tsx +37 -6
  40. package/src/server/client/MarkdownText.tsx +101 -0
  41. package/src/server/client/SubmitModal.tsx +9 -4
  42. package/src/server/client/code-highlight.ts +422 -0
  43. package/src/server/client/constants.ts +130 -24
  44. package/src/server/client/json-highlight.ts +55 -0
  45. package/src/server/client/markdown.ts +153 -0
  46. package/src/server/client/types.ts +23 -0
  47. package/src/server/index.ts +95 -15
  48. package/src/server/run-repository.ts +144 -136
  49. package/src/skill/OUTPUT-FORMAT.md +6 -4
  50. package/src/skill/README.md +22 -14
  51. package/src/skill/SETUP.md +4 -4
  52. package/src/skill/fix.md +5 -5
  53. package/src/skill/hunt.md +27 -18
  54. package/src/skill/train.md +1 -1
  55. package/src/skill/verify.md +5 -5
  56. package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
  57. package/src/tests/baseline.test.ts +76 -34
  58. package/src/tests/cli.test.ts +40 -19
  59. package/src/tests/code-highlight.test.ts +123 -0
  60. package/src/tests/domain.test.ts +1 -1
  61. package/src/tests/findings-commands.test.ts +153 -131
  62. package/src/tests/findings-state.test.ts +6 -3
  63. package/src/tests/fixtures/fake-project.ts +0 -1
  64. package/src/tests/infrastructure.test.ts +33 -38
  65. package/src/tests/markdown.test.ts +89 -0
  66. package/src/tests/review-scripts.test.ts +8 -5
  67. package/src/tests/run-status.test.ts +33 -23
  68. package/src/tests/server.test.ts +108 -75
  69. package/src/tests/storage.test.ts +386 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Área de trabalho (staging) de uma review — biblioteca.
3
+ *
4
+ * O banco é a fonte de verdade de reviews/findings/artefatos. Este diretório
5
+ * guarda só o que os subagentes leem e escrevem: briefs, findings brutos e o
6
+ * contexto da PR. Vive em `<HOUNDSMAN_HOME>/work/<reviewId>/` e é limpo após o
7
+ * `finalize`.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import {
13
+ CONTEXT_BRIEF_FILE,
14
+ CONTEXT_FILE,
15
+ CONTEXT_SUMMARY_FILE,
16
+ displayPath,
17
+ } from "../paths";
18
+ import type { ContextPaths } from "../pr-context";
19
+ import {
20
+ type ContextSummary,
21
+ parseContextSummary,
22
+ parseWatchdogReport,
23
+ type WatchdogReport,
24
+ } from "../report-builder";
25
+ import { systemClock } from "../services/clock";
26
+ import { workRoot } from "./paths";
27
+ import type { ReviewRecord } from "./types";
28
+
29
+ export type { ContextPaths };
30
+
31
+ /** Resultado da leitura dos findings brutos de uma rodada. */
32
+ export type FindingsIngest = {
33
+ reports: WatchdogReport[];
34
+ pending: string[];
35
+ warnings: string[];
36
+ };
37
+
38
+ /** Staging de uma review (briefs/findings/contexto). */
39
+ export class ReviewWorkArea {
40
+ readonly review: ReviewRecord;
41
+ readonly root: string;
42
+
43
+ constructor(review: ReviewRecord, base: string = workRoot()) {
44
+ this.review = review;
45
+ this.root = join(base, review.id);
46
+ }
47
+
48
+ get branch(): string {
49
+ return this.review.branch;
50
+ }
51
+
52
+ get slug(): string {
53
+ return this.review.slug;
54
+ }
55
+
56
+ get briefsDir(): string {
57
+ return join(this.root, "briefs");
58
+ }
59
+
60
+ get findingsDir(): string {
61
+ return join(this.root, "findings");
62
+ }
63
+
64
+ /** Caminho de um arquivo dentro da área de trabalho. */
65
+ file(name: string): string {
66
+ return join(this.root, name);
67
+ }
68
+
69
+ briefFile(id: string): string {
70
+ return join(this.briefsDir, `${id}.md`);
71
+ }
72
+
73
+ findingFile(id: string): string {
74
+ return join(this.findingsDir, `${id}.json`);
75
+ }
76
+
77
+ get contextPaths(): ContextPaths {
78
+ return {
79
+ dir: this.root,
80
+ context: this.file(CONTEXT_FILE),
81
+ summary: this.file(CONTEXT_SUMMARY_FILE),
82
+ brief: this.file(CONTEXT_BRIEF_FILE),
83
+ };
84
+ }
85
+
86
+ /** Garante os diretórios; `clean` recria a área do zero. */
87
+ ensure(clean = false): void {
88
+ if (clean) this.clean();
89
+ mkdirSync(this.briefsDir, { recursive: true });
90
+ mkdirSync(this.findingsDir, { recursive: true });
91
+ }
92
+
93
+ /** Remove todo o staging da review. */
94
+ clean(): void {
95
+ rmSync(this.root, { recursive: true, force: true });
96
+ }
97
+
98
+ /** Remove só os findings brutos (equivalente ao antigo `--clean`). */
99
+ cleanFindings(): void {
100
+ rmSync(this.findingsDir, { recursive: true, force: true });
101
+ mkdirSync(this.findingsDir, { recursive: true });
102
+ }
103
+
104
+ writeBrief(id: string, content: string): void {
105
+ mkdirSync(this.briefsDir, { recursive: true });
106
+ writeFileSync(this.briefFile(id), content);
107
+ }
108
+
109
+ writeContext(context: unknown, brief: string): void {
110
+ mkdirSync(this.root, { recursive: true });
111
+ writeFileSync(this.contextPaths.context, `${JSON.stringify(context, null, 2)}\n`);
112
+ writeFileSync(this.contextPaths.brief, `${brief}\n`);
113
+ }
114
+
115
+ /** Finds brutos gravados pelos subagentes (um JSON por watchdog). */
116
+ readFindings(activeIds: string[]): FindingsIngest {
117
+ const reports: WatchdogReport[] = [];
118
+ const pending: string[] = [];
119
+ const warnings: string[] = [];
120
+ for (const id of activeIds) {
121
+ const path = this.findingFile(id);
122
+ if (!existsSync(path)) {
123
+ pending.push(id);
124
+ continue;
125
+ }
126
+ try {
127
+ const raw = JSON.parse(readFileSync(path, "utf8"));
128
+ const report = parseWatchdogReport(raw, path, (message) =>
129
+ warnings.push(message),
130
+ );
131
+ if (report) reports.push(report);
132
+ else pending.push(id);
133
+ } catch (err) {
134
+ warnings.push(
135
+ `[houndsman] ${displayPath(path)} inválido: ${err instanceof Error ? err.message : err}`,
136
+ );
137
+ pending.push(id);
138
+ }
139
+ }
140
+ return { reports, pending, warnings };
141
+ }
142
+
143
+ /** Resumo do contexto gravado pelo subagente (null se ausente/inválido). */
144
+ readContextSummary(): ContextSummary | null {
145
+ const path = this.contextPaths.summary;
146
+ if (!existsSync(path)) return null;
147
+ try {
148
+ return parseContextSummary(JSON.parse(readFileSync(path, "utf8")));
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /** Conteúdo cru do resumo (para persistir como artefato). */
155
+ readContextSummaryRaw(): string | null {
156
+ const path = this.contextPaths.summary;
157
+ if (!existsSync(path)) return null;
158
+ try {
159
+ return readFileSync(path, "utf8");
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /** Contexto bruto da PR (para persistir como artefato). */
166
+ readContextRaw(): string | null {
167
+ const path = this.contextPaths.context;
168
+ if (!existsSync(path)) return null;
169
+ try {
170
+ return readFileSync(path, "utf8");
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /** Brief do subagente de contexto (para persistir como artefato). */
177
+ readContextBriefRaw(): string | null {
178
+ const path = this.contextPaths.brief;
179
+ if (!existsSync(path)) return null;
180
+ try {
181
+ return readFileSync(path, "utf8");
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /** Toque de `updatedAt` (diagnóstico). */
188
+ touch(): string {
189
+ return systemClock();
190
+ }
191
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export type {
16
16
  HoundsmanConcurrency,
17
17
  HoundsmanConfig,
18
18
  HoundsmanConfigInput,
19
+ HoundsmanStorage,
19
20
  } from "./core/config";
20
21
  export {
21
22
  DEFAULT_ALWAYS_INCLUDE_DOCS,
@@ -75,6 +76,7 @@ export type {
75
76
  FindingDecision,
76
77
  FindingExecState,
77
78
  FindingHistoryEntry,
79
+ FindingIdResolution,
78
80
  FindingState,
79
81
  FindingsFilter,
80
82
  FindingsStateFile,
@@ -85,7 +87,6 @@ export type {
85
87
  export {
86
88
  applyFindingUpdate,
87
89
  buildStateFromFindings,
88
- buildStateFromRunDir,
89
90
  compareFindings,
90
91
  emptyState,
91
92
  FINDING_DECISIONS,
@@ -99,20 +100,18 @@ export {
99
100
  filterFindings,
100
101
  newFindingId,
101
102
  reorder,
102
- runDir,
103
- stateFile,
103
+ resolveFindingId,
104
104
  summarize,
105
105
  syncState,
106
106
  } from "./core/findings-state";
107
107
  export type { GitResult, GitRunner } from "./core/git-runner";
108
108
  export {
109
- ARTIFACT_DIR,
110
109
  CORE_DIR,
111
110
  CORE_SKILL_DIR,
111
+ displayPath,
112
112
  OUTPUT_FORMAT_PATH,
113
113
  PROJECT_DIR,
114
114
  relFromRoot,
115
- STATE_FILE,
116
115
  } from "./core/paths";
117
116
  export { ProjectConfig } from "./core/project-config";
118
117
  export { checkRegistry } from "./core/registry-check";
@@ -121,7 +120,6 @@ export { ManifestWriter } from "./core/renderers/manifest-writer";
121
120
  export { ReportRenderer } from "./core/renderers/report-renderer";
122
121
  export type { DomainReport } from "./core/report-builder";
123
122
  export { renderDomainReport } from "./core/report-builder";
124
- export { RunLayout } from "./core/run-layout";
125
123
  export type { Clock } from "./core/services/clock";
126
124
  export { systemClock } from "./core/services/clock";
127
125
  export { FileClassifier } from "./core/services/file-classifier";
@@ -131,6 +129,29 @@ export { GhClient } from "./core/services/gh-client";
131
129
  export { GitRepository } from "./core/services/git-repository";
132
130
  export { ProjectFiles } from "./core/services/project-files";
133
131
  export { Verifier } from "./core/services/verifier";
132
+ export {
133
+ createMemoryStorage,
134
+ createStorage,
135
+ } from "./core/storage/factory";
136
+ export { FileStorageProvider } from "./core/storage/file/file-storage";
137
+ export {
138
+ buildRepoIdentity,
139
+ normalizeRemote,
140
+ resolveRepoIdentity,
141
+ } from "./core/storage/repo-identity";
142
+ export { SQLiteStorageProvider } from "./core/storage/sqlite/sqlite-storage";
143
+ export type {
144
+ ArtifactKind,
145
+ ArtifactRecord,
146
+ CreateReviewInput,
147
+ RepoIdentity,
148
+ ReviewPatch,
149
+ ReviewRecord,
150
+ ReviewStatus,
151
+ StorageProvider,
152
+ WriteArtifactInput,
153
+ } from "./core/storage/types";
154
+ export { ReviewWorkArea } from "./core/storage/work-area";
134
155
  export type {
135
156
  ChurnMap,
136
157
  ClassifyRule,
@@ -1,67 +1,14 @@
1
1
  /**
2
- * Descoberta de runs e leitura segura de artefatos (servidor).
2
+ * Validação do slug da run (`branch-slug`) usado nas URLs do servidor.
3
3
  *
4
- * Toda leitura é restrita à pasta da run — `readRunFile` barra path traversal.
4
+ * Os dados vivem no storage global; a única preocupação restante aqui é barrar
5
+ * slugs malformados antes de consultar o provider.
5
6
  */
6
7
 
7
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
- import { isAbsolute, join, relative, resolve } from "node:path";
8
+ /** Slugs aceitos: letras, números, ponto, underscore e hífen. */
9
+ const SLUG_PATTERN = /^[a-zA-Z0-9._-]+$/;
9
10
 
10
- /** Arquivos que marcam uma pasta como "run" do houndsman. */
11
- const RUN_MARKERS = [
12
- "manifest.json",
13
- "state.json",
14
- "run.json",
15
- "classification.json",
16
- "review.md",
17
- ] as const;
18
-
19
- /** Slugs (nomes de pasta) das runs sob o `artifactDir`. */
20
- export function listRunSlugs(artifactDir: string): string[] {
21
- if (!existsSync(artifactDir)) return [];
22
- try {
23
- return readdirSync(artifactDir, { withFileTypes: true })
24
- .filter((entry) => entry.isDirectory())
25
- .map((entry) => entry.name)
26
- .filter((slug) =>
27
- RUN_MARKERS.some((marker) => existsSync(join(artifactDir, slug, marker))),
28
- )
29
- .sort();
30
- } catch {
31
- return [];
32
- }
33
- }
34
-
35
- /** Diretório absoluto de uma run; null se o slug for inválido/inexistente. */
36
- export function runDirForSlug(artifactDir: string, slug: string): string | null {
37
- if (!/^[a-zA-Z0-9._-]+$/.test(slug)) return null;
38
- const dir = join(artifactDir, slug);
39
- if (!existsSync(dir) || !statSync(dir).isDirectory()) return null;
40
- return dir;
41
- }
42
-
43
- /** Lê um arquivo DENTRO da run; null se ausente ou fora do diretório. */
44
- export function readRunFile(runDir: string, name: string): string | null {
45
- const root = resolve(runDir);
46
- const target = resolve(root, name);
47
- const rel = relative(root, target);
48
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
49
- try {
50
- return existsSync(target) && statSync(target).isFile()
51
- ? readFileSync(target, "utf8")
52
- : null;
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- /** Lê e faz parse de um JSON da run (null se ausente/inválido). */
59
- export function readRunJson<T>(runDir: string, name: string): T | null {
60
- const raw = readRunFile(runDir, name);
61
- if (raw === null) return null;
62
- try {
63
- return JSON.parse(raw) as T;
64
- } catch {
65
- return null;
66
- }
11
+ /** `true` se o slug é seguro/plausível. */
12
+ export function isSlug(value: string): boolean {
13
+ return SLUG_PATTERN.test(value);
67
14
  }
@@ -1,48 +1,162 @@
1
1
  /**
2
- * Painel de artefatos: abas para ver o conteúdo bruto dos arquivos da run.
2
+ * Painel de artefatos: lista todos os artefatos persistidos da run e renderiza
3
+ * o conteúdo bruto. JSON ganha destaque de sintaxe; briefs e relatórios são
4
+ * auditáveis pela mesma navegação.
3
5
  */
4
6
 
5
- import { useEffect, useState } from "preact/hooks";
6
- import { ARTIFACT_INFO, ARTIFACTS, type ArtifactName } from "./constants";
7
+ import { useEffect, useMemo, useState } from "preact/hooks";
8
+ import { api } from "./api";
9
+ import {
10
+ ARTIFACT_KIND_LABELS,
11
+ describeArtifact,
12
+ displayArtifactName,
13
+ groupArtifacts,
14
+ isJsonArtifact,
15
+ } from "./constants";
16
+ import { formatJson, highlightJson } from "./json-highlight";
17
+ import type { ArtifactMeta } from "./types";
18
+
19
+ /** Prefere o relatório consolidado; senão, o primeiro artefato disponível. */
20
+ function pickDefault(items: ArtifactMeta[]): string {
21
+ return (items.find((item) => item.name === "review.md") ?? items[0])?.name ?? "";
22
+ }
23
+
24
+ /** JSON com destaque de sintaxe (tokens em spans, sem lib externa). */
25
+ function JsonView({ content }: { content: string }) {
26
+ const tokens = useMemo(() => highlightJson(formatJson(content)), [content]);
27
+ return (
28
+ <pre class="artifact-view json-view">
29
+ {tokens.map((token) => (
30
+ <span class={token.type === "plain" ? undefined : `json-${token.type}`}>
31
+ {token.text}
32
+ </span>
33
+ ))}
34
+ </pre>
35
+ );
36
+ }
7
37
 
8
38
  export function ArtifactsPanel({ slug }: { slug: string }) {
9
- const [name, setName] = useState<ArtifactName>(ARTIFACTS[0]);
39
+ const [items, setItems] = useState<ArtifactMeta[]>([]);
40
+ const [name, setName] = useState("");
10
41
  const [content, setContent] = useState("");
11
42
  const [error, setError] = useState("");
43
+ const [loading, setLoading] = useState(false);
44
+
45
+ useEffect(() => {
46
+ let cancelled = false;
47
+ setError("");
48
+ api<{ artifacts: ArtifactMeta[] }>(`/api/runs/${slug}/artifacts`)
49
+ .then(({ artifacts }) => {
50
+ if (cancelled) return;
51
+ setItems(artifacts);
52
+ setName((current) =>
53
+ artifacts.some((item) => item.name === current)
54
+ ? current
55
+ : pickDefault(artifacts),
56
+ );
57
+ })
58
+ .catch((err) => {
59
+ if (cancelled) return;
60
+ setItems([]);
61
+ setName("");
62
+ setError(String(err));
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [slug]);
12
68
 
13
69
  useEffect(() => {
70
+ if (!name) {
71
+ setContent("");
72
+ return;
73
+ }
74
+ let cancelled = false;
75
+ setLoading(true);
14
76
  setError("");
15
- fetch(`/api/runs/${slug}/artifacts/${name}`)
77
+ fetch(`/api/runs/${slug}/artifacts/${encodeURI(name)}`)
16
78
  .then(async (res) => {
17
79
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
18
- setContent(await res.text());
80
+ const text = await res.text();
81
+ if (!cancelled) setContent(text);
19
82
  })
20
83
  .catch((err) => {
84
+ if (cancelled) return;
21
85
  setContent("");
22
86
  setError(String(err));
87
+ })
88
+ .finally(() => {
89
+ if (!cancelled) setLoading(false);
23
90
  });
91
+ return () => {
92
+ cancelled = true;
93
+ };
24
94
  }, [slug, name]);
25
95
 
96
+ const groups = useMemo(() => groupArtifacts(items), [items]);
97
+ const selected = useMemo(
98
+ () => items.find((item) => item.name === name),
99
+ [items, name],
100
+ );
101
+
26
102
  return (
27
103
  <div class="artifacts">
28
- <div class="artifact-tabs">
29
- {ARTIFACTS.map((artifact) => (
30
- <button
31
- type="button"
32
- title={ARTIFACT_INFO[artifact]}
33
- class={`chip${artifact === name ? " active" : ""}`}
34
- onClick={() => setName(artifact)}
35
- >
36
- {artifact}
37
- </button>
38
- ))}
104
+ <div class="artifact-layout">
105
+ <nav class="artifact-list" aria-label="Artefatos da revisão">
106
+ {groups.map((group) => (
107
+ <div class="artifact-group" key={group.key}>
108
+ <p class="artifact-group-label">{group.label}</p>
109
+ {group.items.map((item) => (
110
+ <button
111
+ type="button"
112
+ key={item.name}
113
+ title={item.name}
114
+ class={`artifact-item${item.name === name ? " active" : ""}`}
115
+ onClick={() => setName(item.name)}
116
+ >
117
+ <span class="artifact-item-name">
118
+ {displayArtifactName(item)}
119
+ </span>
120
+ <span class="artifact-item-kind">
121
+ {ARTIFACT_KIND_LABELS[item.kind] ?? item.kind}
122
+ </span>
123
+ </button>
124
+ ))}
125
+ </div>
126
+ ))}
127
+ {items.length === 0 && !loading && (
128
+ <p class="empty">Nenhum artefato persistido nesta run.</p>
129
+ )}
130
+ </nav>
131
+
132
+ <section class="artifact-pane">
133
+ {selected ? (
134
+ <header class="artifact-head">
135
+ <div>
136
+ <h3 class="artifact-title">{selected.name}</h3>
137
+ <p class="artifact-desc">{describeArtifact(selected)}</p>
138
+ </div>
139
+ <span class="artifact-kind-badge">
140
+ {ARTIFACT_KIND_LABELS[selected.kind] ?? selected.kind}
141
+ </span>
142
+ </header>
143
+ ) : null}
144
+
145
+ {error ? (
146
+ <p class="error">{error}</p>
147
+ ) : loading ? (
148
+ <p class="empty">Carregando artefato…</p>
149
+ ) : selected ? (
150
+ isJsonArtifact(selected) ? (
151
+ <JsonView content={content} />
152
+ ) : (
153
+ <pre class="artifact-view">{content || "—"}</pre>
154
+ )
155
+ ) : (
156
+ <p class="empty">Selecione um artefato para visualizar.</p>
157
+ )}
158
+ </section>
39
159
  </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
160
  </div>
47
161
  );
48
162
  }
@@ -6,8 +6,9 @@
6
6
  * poluição visual da triagem; reabrir volta a expandir.
7
7
  */
8
8
 
9
- import { useCallback, useEffect, useRef, useState } from "preact/hooks";
9
+ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
10
10
  import { api } from "./api";
11
+ import { highlightCode, languageFromFile } from "./code-highlight";
11
12
  import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS } from "./constants";
12
13
  import { vscodeFileLink } from "./format";
13
14
  import {
@@ -19,6 +20,7 @@ import {
19
20
  Spinner,
20
21
  Watch,
21
22
  } from "./icons";
23
+ import { MarkdownText } from "./MarkdownText";
22
24
  import type { Finding } from "./types";
23
25
 
24
26
  /** Debounce do auto-save do comentário (ms). */
@@ -102,6 +104,13 @@ export function FindingCard({
102
104
 
103
105
  const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
104
106
  const href = finding.absFile ? vscodeFileLink(finding.absFile, finding.line) : null;
107
+ const highlighted = useMemo(
108
+ () =>
109
+ finding.evidence
110
+ ? highlightCode(finding.evidence, languageFromFile(finding.file))
111
+ : null,
112
+ [finding.evidence, finding.file],
113
+ );
105
114
  const toggle = () => setOpen((current) => !current);
106
115
 
107
116
  return (
@@ -186,14 +195,36 @@ export function FindingCard({
186
195
  <div class="meta-row">{finding.rule}</div>
187
196
 
188
197
  <div class="action-block">
189
- <p>
190
- <Sparkles size={16} class="action-icon" />
198
+ <Sparkles size={16} class="action-icon" />
199
+ <div class="action-text">
191
200
  <span class="action-label">Ação:</span>
192
- {finding.action}
193
- </p>
201
+ <MarkdownText text={finding.action} />
202
+ </div>
194
203
  </div>
195
204
 
196
- {finding.evidence && <pre class="evidence">{finding.evidence}</pre>}
205
+ {finding.evidence &&
206
+ (highlighted ? (
207
+ <div class="evidence-block">
208
+ <div class="evidence-head">
209
+ <span class="code-lang">{highlighted.label}</span>
210
+ </div>
211
+ <pre class="evidence code-evidence">
212
+ {highlighted.tokens.map((token) => (
213
+ <span
214
+ class={
215
+ token.type === "plain"
216
+ ? undefined
217
+ : `tok-${token.type}`
218
+ }
219
+ >
220
+ {token.text}
221
+ </span>
222
+ ))}
223
+ </pre>
224
+ </div>
225
+ ) : (
226
+ <pre class="evidence">{finding.evidence}</pre>
227
+ ))}
197
228
 
198
229
  <div class="decision-tags-block">
199
230
  <div class="decisions">