@payconductor/houndsman 0.0.0-stage → 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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -3
  3. package/assets/houndsman.png +0 -0
  4. package/assets/logo-legacy.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/package.json +64 -5
  7. package/src/cli/index.ts +290 -0
  8. package/src/core/assets.ts +28 -0
  9. package/src/core/brief.ts +185 -0
  10. package/src/core/commands/context.ts +39 -0
  11. package/src/core/commands/findings.ts +390 -0
  12. package/src/core/commands/review.ts +50 -0
  13. package/src/core/commands/scaffold.ts +393 -0
  14. package/src/core/commands/setup.ts +75 -0
  15. package/src/core/commands/test.ts +20 -0
  16. package/src/core/commands/update.ts +30 -0
  17. package/src/core/commands/watchdog.ts +105 -0
  18. package/src/core/config-loader.ts +87 -0
  19. package/src/core/config.ts +101 -0
  20. package/src/core/diff-source.ts +140 -0
  21. package/src/core/doc-discoverer.ts +33 -0
  22. package/src/core/file-classifier.ts +295 -0
  23. package/src/core/finding-anchor.ts +147 -0
  24. package/src/core/findings-state.ts +538 -0
  25. package/src/core/jev/index.ts +12 -0
  26. package/src/core/jev/jev-candidates.ts +105 -0
  27. package/src/core/jev/jev-client.ts +160 -0
  28. package/src/core/jev/jev-detector.ts +205 -0
  29. package/src/core/jev/jev-questions.ts +92 -0
  30. package/src/core/jev/jev-types.ts +91 -0
  31. package/src/core/orchestrator.ts +596 -0
  32. package/src/core/paths.ts +33 -0
  33. package/src/core/pr-context.ts +271 -0
  34. package/src/core/registry-check.ts +160 -0
  35. package/src/core/report-builder.ts +461 -0
  36. package/src/core/types.ts +147 -0
  37. package/src/core/watchdog-registry.ts +43 -0
  38. package/src/index.ts +132 -0
  39. package/src/server/artifacts.ts +66 -0
  40. package/src/server/client/App.tsx +158 -0
  41. package/src/server/client/ArtifactsPanel.tsx +48 -0
  42. package/src/server/client/Filters.tsx +83 -0
  43. package/src/server/client/FindingCard.tsx +122 -0
  44. package/src/server/client/FindingsList.tsx +54 -0
  45. package/src/server/client/RunList.tsx +43 -0
  46. package/src/server/client/SummaryBar.tsx +21 -0
  47. package/src/server/client/api.ts +37 -0
  48. package/src/server/client/constants.ts +63 -0
  49. package/src/server/client/index.tsx +12 -0
  50. package/src/server/client/types.ts +48 -0
  51. package/src/server/client-build.ts +47 -0
  52. package/src/server/index.ts +365 -0
  53. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  54. package/src/skill/OUTPUT-FORMAT.md +70 -0
  55. package/src/skill/README.md +124 -0
  56. package/src/skill/SETUP.md +101 -0
  57. package/src/skill/example-review.md +58 -0
  58. package/src/skill/houndsman-update.md +251 -0
  59. package/src/skill/houndsman.md +316 -0
  60. package/src/tests/findings-anchor.test.ts +120 -0
  61. package/src/tests/findings-commands.test.ts +226 -0
  62. package/src/tests/findings-state.test.ts +274 -0
  63. package/src/tests/fixtures/fake-project.ts +64 -0
  64. package/src/tests/review-scripts.test.ts +1150 -0
  65. package/src/tests/server.test.ts +212 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Identidade e reancoragem de findings (biblioteca).
3
+ *
4
+ * Um finding NÃO é identificado pela linha: a linha desloca após um auto-fix.
5
+ * A identidade é o `fingerprint` (arquivo + regra + âncora de conteúdo); a linha
6
+ * é apenas cache, recalculada varrendo o arquivo (`locateAnchor`).
7
+ *
8
+ * Âncora = linha ofensora normalizada + contexto (linha anterior/seguinte).
9
+ * Quando o fix altera a linha ofensora, a âncora some → o finding fica `stale`.
10
+ *
11
+ * Ocorrências byte-idênticas (mesmo snippet E mesmo contexto) compartilham o
12
+ * fingerprint: são tratadas como um grupo (limitação conhecida).
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+
17
+ /** Âncora de conteúdo de um finding (independente do número da linha). */
18
+ export type FindingAnchor = {
19
+ /** Linha ofensora normalizada. */
20
+ snippet: string;
21
+ /** Linha anterior normalizada (ausente se a ofensora for a primeira). */
22
+ contextBefore?: string;
23
+ /** Linha seguinte normalizada (ausente se a ofensora for a última). */
24
+ contextAfter?: string;
25
+ /** Última linha conhecida (1-based) — cache, nunca identidade. */
26
+ line: number;
27
+ };
28
+
29
+ /** Resultado da localização de uma âncora no conteúdo atual. */
30
+ export type AnchorLocation = {
31
+ /** Linha atual (1-based) ou null se a âncora não existe mais. */
32
+ line: number | null;
33
+ /** Nº de ocorrências da âncora no arquivo. */
34
+ occurrences: number;
35
+ };
36
+
37
+ /** Normaliza uma linha: colapsa espaços internos e apara as pontas. */
38
+ export function normalizeLine(raw: string): string {
39
+ return raw.replace(/\s+/g, " ").trim();
40
+ }
41
+
42
+ /** Conteúdo → linhas normalizadas (uma por linha do arquivo). */
43
+ export function normalizedLines(content: string): string[] {
44
+ return content.split(/\r?\n/).map(normalizeLine);
45
+ }
46
+
47
+ /** Marcador de contexto ausente (não confundir com linha vazia). */
48
+ const ABSENT = "\u0001";
49
+
50
+ /** Chave estável da âncora (snippet + contexto, SEM a linha). */
51
+ function anchorKey(anchor: FindingAnchor): string {
52
+ return [
53
+ anchor.snippet,
54
+ anchor.contextBefore ?? ABSENT,
55
+ anchor.contextAfter ?? ABSENT,
56
+ ].join("\u0000");
57
+ }
58
+
59
+ /** Hash estável (sha256 truncado) — base das identidades do motor. */
60
+ export function hashKey(input: string): string {
61
+ return createHash("sha256").update(input).digest("hex").slice(0, 16);
62
+ }
63
+
64
+ /**
65
+ * Fingerprint do finding: `arquivo + regra + âncora`. Sem a linha — permanece
66
+ * estável após edições que deslocam o trecho. Sem âncora, cai no `fallback`
67
+ * (ex.: `message`). Ocorrências com a MESMA âncora compartilham o fingerprint.
68
+ */
69
+ export function fingerprintOf(
70
+ file: string,
71
+ rule: string,
72
+ anchor: FindingAnchor | null,
73
+ fallback = "",
74
+ ): string {
75
+ const key = anchor ? anchorKey(anchor) : `fallback:${fallback}`;
76
+ return hashKey(`${file}\u0000${rule}\u0000${key}`);
77
+ }
78
+
79
+ /**
80
+ * Monta a âncora a partir do conteúdo: usa a `line` (1-based) quando válida;
81
+ * senão procura a linha que contém a `evidence`. Retorna null se não localizar
82
+ * (ex.: arquivo deletado/binário).
83
+ */
84
+ export function buildAnchor(
85
+ content: string,
86
+ line: number | undefined,
87
+ evidence?: string,
88
+ ): FindingAnchor | null {
89
+ const lines = normalizedLines(content);
90
+
91
+ let index = -1;
92
+ if (line !== undefined && line >= 1 && line <= lines.length) {
93
+ index = line - 1;
94
+ } else if (evidence) {
95
+ const needle = normalizeLine(evidence);
96
+ if (needle) index = lines.findIndex((l) => l.includes(needle));
97
+ }
98
+ if (index < 0) return null;
99
+
100
+ const anchor: FindingAnchor = { snippet: lines[index], line: index + 1 };
101
+ if (index - 1 >= 0) anchor.contextBefore = lines[index - 1];
102
+ if (index + 1 < lines.length) anchor.contextAfter = lines[index + 1];
103
+ return anchor;
104
+ }
105
+
106
+ /** `true` se a linha `i` casa a âncora (snippet + contexto). */
107
+ function matchesAt(lines: string[], i: number, anchor: FindingAnchor): boolean {
108
+ if (lines[i] !== anchor.snippet) return false;
109
+ if (anchor.contextBefore !== undefined && lines[i - 1] !== anchor.contextBefore) {
110
+ return false;
111
+ }
112
+ if (anchor.contextAfter !== undefined && lines[i + 1] !== anchor.contextAfter) {
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ /** Localiza a âncora no conteúdo atual (linha + nº de ocorrências). */
119
+ export function locateAnchor(
120
+ content: string,
121
+ anchor: FindingAnchor | null,
122
+ ): AnchorLocation {
123
+ if (!anchor) return { line: null, occurrences: 0 };
124
+ const lines = normalizedLines(content);
125
+ let occurrences = 0;
126
+ let first = -1;
127
+ for (let i = 0; i < lines.length; i++) {
128
+ if (!matchesAt(lines, i, anchor)) continue;
129
+ occurrences++;
130
+ if (first < 0) first = i;
131
+ }
132
+ return { line: first < 0 ? null : first + 1, occurrences };
133
+ }
134
+
135
+ export type ReanchorStatus = "ok" | "moved" | "stale";
136
+
137
+ /** Reancora um finding: recalcula a linha e classifica o deslocamento. */
138
+ export function reanchor(
139
+ content: string,
140
+ anchor: FindingAnchor | null,
141
+ previousLine: number | null,
142
+ ): { status: ReanchorStatus; line: number | null } {
143
+ const { line } = locateAnchor(content, anchor);
144
+ if (line === null) return { status: "stale", line: null };
145
+ if (previousLine !== null && line === previousLine) return { status: "ok", line };
146
+ return { status: "moved", line };
147
+ }
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Estado de triagem dos findings (biblioteca).
3
+ *
4
+ * Persistido em `.houndsman/.runs/<slug>/state.json` (local — coberto pelo
5
+ * `.gitignore` gerado, que ignora `.runs/`). Cada finding tem um `id` curto e um
6
+ * `fingerprint` de conteúdo: nas re-execuções do `--finalize` o estado é
7
+ * preservado casando por fingerprint.
8
+ *
9
+ * A `line` é cache — `syncState` a recalcula após edições (auto-fix) e marca
10
+ * `stale` quando a âncora some.
11
+ */
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import {
17
+ buildAnchor,
18
+ type FindingAnchor,
19
+ fingerprintOf,
20
+ locateAnchor,
21
+ reanchor,
22
+ } from "./finding-anchor";
23
+ import { STATE_FILE } from "./paths";
24
+ import {
25
+ dedupeFindings,
26
+ type MergedFinding,
27
+ parseWatchdogReport,
28
+ sanitizeBranch,
29
+ type WatchdogReport,
30
+ } from "./report-builder";
31
+ import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tipos
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Decisão de triagem: o que fazer com o finding. */
38
+ export type FindingDecision = "pending" | "execute" | "ignore";
39
+
40
+ /** Estado de execução: em que pé está a ação. */
41
+ export type FindingExecState = "pending" | "in_progress" | "done" | "stale";
42
+
43
+ /** Valores canônicos (na ordem de exibição). */
44
+ export const FINDING_DECISIONS: readonly FindingDecision[] = [
45
+ "pending",
46
+ "execute",
47
+ "ignore",
48
+ ];
49
+
50
+ /** Valores canônicos de `execState`. */
51
+ export const FINDING_EXEC_STATES: readonly FindingExecState[] = [
52
+ "pending",
53
+ "in_progress",
54
+ "done",
55
+ "stale",
56
+ ];
57
+
58
+ const DECISION_SET = new Set<string>(FINDING_DECISIONS);
59
+ const EXEC_SET = new Set<string>(FINDING_EXEC_STATES);
60
+
61
+ /** Entrada de auditoria (quem mudou o quê). */
62
+ export type FindingHistoryEntry = {
63
+ at: string;
64
+ decision: FindingDecision;
65
+ execState: FindingExecState;
66
+ comment?: string;
67
+ by?: string;
68
+ };
69
+
70
+ /** Um finding triável, com o estado das duas dimensões. */
71
+ export type FindingState = {
72
+ id: string;
73
+ fingerprint: string;
74
+ watchdogIds: string[];
75
+ rule: string;
76
+ severity: Severity;
77
+ file: string;
78
+ line: number | null;
79
+ message: string;
80
+ action: string;
81
+ evidence?: string;
82
+ anchor: FindingAnchor | null;
83
+ /** Ocorrências idênticas colapsadas neste fingerprint (>1 = grupo). */
84
+ count: number;
85
+ /** Ainda presente no último relatório consolidado. */
86
+ present: boolean;
87
+ decision: FindingDecision;
88
+ comment?: string;
89
+ execState: FindingExecState;
90
+ createdAt: string;
91
+ updatedAt: string;
92
+ history: FindingHistoryEntry[];
93
+ };
94
+
95
+ /** Conteúdo do `state.json`. */
96
+ export type FindingsStateFile = {
97
+ version: 1;
98
+ branch: string;
99
+ slug: string;
100
+ updatedAt: string;
101
+ /** Ids na ordem de exibição (estável). */
102
+ order: string[];
103
+ findings: Record<string, FindingState>;
104
+ };
105
+
106
+ export const FINDINGS_STATE_VERSION = 1;
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Caminhos
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Diretório de artefatos da run (branch-slug). */
113
+ export function runDir(artifactDir: string, branch: string): string {
114
+ return join(artifactDir, sanitizeBranch(branch));
115
+ }
116
+
117
+ /** Caminho do `state.json` da run. */
118
+ export function stateFile(artifactDir: string, branch: string): string {
119
+ return join(runDir(artifactDir, branch), STATE_FILE);
120
+ }
121
+
122
+ /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
123
+ export type FileReader = (relativePath: string) => string | null;
124
+
125
+ /** Leitor padrão a partir do cwd (null se ausente/ilegível). */
126
+ export function fileReader(cwd: string): FileReader {
127
+ return (relativePath: string) => {
128
+ try {
129
+ const full = join(cwd, relativePath);
130
+ return existsSync(full) ? readFileSync(full, "utf8") : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ };
135
+ }
136
+
137
+ /** Id curto aleatório (hex) para um finding novo. */
138
+ export function newFindingId(): string {
139
+ return randomUUID().replace(/-/g, "").slice(0, 10);
140
+ }
141
+
142
+ /** Estado vazio para uma branch. */
143
+ export function emptyState(branch: string): FindingsStateFile {
144
+ return {
145
+ version: FINDINGS_STATE_VERSION,
146
+ branch,
147
+ slug: sanitizeBranch(branch),
148
+ updatedAt: new Date().toISOString(),
149
+ order: [],
150
+ findings: {},
151
+ };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Ordenação / resumo
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /** Presentes primeiro; depois severidade, arquivo e linha. */
159
+ export function compareFindings(a: FindingState, b: FindingState): number {
160
+ if (a.present !== b.present) return a.present ? -1 : 1;
161
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
162
+ if (bySeverity !== 0) return bySeverity;
163
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
164
+ return (a.line ?? 0) - (b.line ?? 0);
165
+ }
166
+
167
+ /** Ordena os ids do estado (recomputa `order`). */
168
+ export function reorder(state: FindingsStateFile): void {
169
+ state.order = Object.values(state.findings)
170
+ .sort(compareFindings)
171
+ .map((f) => f.id);
172
+ }
173
+
174
+ export type FindingsSummary = {
175
+ total: number;
176
+ present: number;
177
+ byDecision: Record<FindingDecision, number>;
178
+ byExecState: Record<FindingExecState, number>;
179
+ bySeverity: Record<Severity, number>;
180
+ };
181
+
182
+ /** Contagens por decisão, execução e severidade. */
183
+ export function summarize(state: FindingsStateFile): FindingsSummary {
184
+ const byDecision = Object.fromEntries(FINDING_DECISIONS.map((d) => [d, 0])) as Record<
185
+ FindingDecision,
186
+ number
187
+ >;
188
+ const byExecState = Object.fromEntries(
189
+ FINDING_EXEC_STATES.map((e) => [e, 0]),
190
+ ) as Record<FindingExecState, number>;
191
+ const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0])) as Record<
192
+ Severity,
193
+ number
194
+ >;
195
+
196
+ let total = 0;
197
+ let present = 0;
198
+ for (const finding of Object.values(state.findings)) {
199
+ total++;
200
+ if (finding.present) present++;
201
+ byDecision[finding.decision]++;
202
+ byExecState[finding.execState]++;
203
+ bySeverity[finding.severity]++;
204
+ }
205
+ return { total, present, byDecision, byExecState, bySeverity };
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Filtro
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export type FindingsFilter = {
213
+ decision?: FindingDecision;
214
+ execState?: FindingExecState;
215
+ watchdog?: string;
216
+ severity?: Severity;
217
+ /** Substring do caminho (case-insensitive). */
218
+ file?: string;
219
+ /** Inclui findings ausentes do último relatório (default: só presentes). */
220
+ includeAbsent?: boolean;
221
+ };
222
+
223
+ /** Aplica o filtro e devolve os findings ordenados. */
224
+ export function filterFindings(
225
+ state: FindingsStateFile,
226
+ filter: FindingsFilter = {},
227
+ ): FindingState[] {
228
+ const needle = filter.file?.toLowerCase();
229
+ return Object.values(state.findings)
230
+ .filter((finding) => {
231
+ if (!filter.includeAbsent && !finding.present) return false;
232
+ if (filter.decision && finding.decision !== filter.decision) return false;
233
+ if (filter.execState && finding.execState !== filter.execState) return false;
234
+ if (filter.severity && finding.severity !== filter.severity) return false;
235
+ if (filter.watchdog && !finding.watchdogIds.includes(filter.watchdog)) {
236
+ return false;
237
+ }
238
+ if (needle && !finding.file.toLowerCase().includes(needle)) return false;
239
+ return true;
240
+ })
241
+ .sort(compareFindings);
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Construção (finalize)
246
+ // ---------------------------------------------------------------------------
247
+
248
+ export type BuildStateOptions = {
249
+ branch: string;
250
+ readFile: FileReader;
251
+ previous?: FindingsStateFile | null;
252
+ now?: string;
253
+ };
254
+
255
+ /**
256
+ * Constrói o estado a partir dos findings consolidados, preservando o que já
257
+ * existia (casando por `fingerprint`). Depende de ler o arquivo para ancorar;
258
+ * sem conteúdo (deletado/binário) a âncora fica nula e o fingerprint cai no
259
+ * `message`.
260
+ *
261
+ * Muta cada `finding` recebido com o `id` correspondente (para o relatório).
262
+ */
263
+ export function buildStateFromFindings(
264
+ merged: readonly MergedFinding[],
265
+ options: BuildStateOptions,
266
+ ): FindingsStateFile {
267
+ const now = options.now ?? new Date().toISOString();
268
+ const previous = options.previous ?? null;
269
+
270
+ const result: Record<string, FindingState> = {};
271
+ // 1. Preserva o estado anterior (marca como ausente até provar o contrário).
272
+ for (const prev of Object.values(previous?.findings ?? {})) {
273
+ result[prev.id] = {
274
+ ...prev,
275
+ present: false,
276
+ execState:
277
+ prev.execState === "pending" && prev.decision === "execute"
278
+ ? "done"
279
+ : prev.execState,
280
+ updatedAt: now,
281
+ };
282
+ }
283
+
284
+ // 2. Findings atuais, agrupados por fingerprint.
285
+ const byFingerprint = new Map<string, FindingState>();
286
+ for (const finding of merged) {
287
+ const content = options.readFile(finding.file);
288
+ const anchor = content
289
+ ? buildAnchor(content, finding.line, finding.evidence)
290
+ : null;
291
+ const fingerprint = fingerprintOf(
292
+ finding.file,
293
+ finding.rule,
294
+ anchor,
295
+ finding.message,
296
+ );
297
+
298
+ const group = byFingerprint.get(fingerprint);
299
+ if (group) {
300
+ group.count++;
301
+ for (const w of finding.watchdogs) {
302
+ if (!group.watchdogIds.includes(w)) group.watchdogIds.push(w);
303
+ }
304
+ finding.id = group.id;
305
+ continue;
306
+ }
307
+
308
+ const prev = previous
309
+ ? Object.values(previous.findings).find((f) => f.fingerprint === fingerprint)
310
+ : undefined;
311
+ const created: FindingState = {
312
+ id: prev?.id ?? newFindingId(),
313
+ fingerprint,
314
+ watchdogIds: [...finding.watchdogs],
315
+ rule: finding.rule,
316
+ severity: finding.severity,
317
+ file: finding.file,
318
+ line: anchor ? anchor.line : null,
319
+ message: finding.message,
320
+ action: finding.action,
321
+ evidence: finding.evidence,
322
+ anchor,
323
+ count: 1,
324
+ present: true,
325
+ decision: prev?.decision ?? "pending",
326
+ comment: prev?.comment,
327
+ execState: prev?.execState ?? "pending",
328
+ createdAt: prev?.createdAt ?? now,
329
+ updatedAt: now,
330
+ history: prev?.history ?? [],
331
+ };
332
+ byFingerprint.set(fingerprint, created);
333
+ result[created.id] = created;
334
+ finding.id = created.id;
335
+ }
336
+
337
+ const state: FindingsStateFile = {
338
+ version: FINDINGS_STATE_VERSION,
339
+ branch: options.branch,
340
+ slug: sanitizeBranch(options.branch),
341
+ updatedAt: now,
342
+ order: [],
343
+ findings: result,
344
+ };
345
+ reorder(state);
346
+ return state;
347
+ }
348
+
349
+ /**
350
+ * Reconstrói o estado a partir dos artefatos da run (`classification.json` +
351
+ * `findings/<id>.json`), sem exigir `--finalize`. Devolve null se não houver
352
+ * findings gravados.
353
+ */
354
+ export function buildStateFromRunDir(
355
+ outDir: string,
356
+ branch: string,
357
+ readFile: FileReader,
358
+ ): FindingsStateFile | null {
359
+ const classificationPath = join(outDir, "classification.json");
360
+ if (!existsSync(classificationPath)) return null;
361
+
362
+ let activeIds: string[] = [];
363
+ try {
364
+ const classification = JSON.parse(readFileSync(classificationPath, "utf8")) as {
365
+ watchdogs?: Record<string, string[]>;
366
+ };
367
+ activeIds = Object.keys(classification.watchdogs ?? {});
368
+ } catch {
369
+ return null;
370
+ }
371
+
372
+ const reports: WatchdogReport[] = [];
373
+ for (const id of activeIds) {
374
+ const path = join(outDir, "findings", `${id}.json`);
375
+ if (!existsSync(path)) continue;
376
+ try {
377
+ const report = parseWatchdogReport(
378
+ JSON.parse(readFileSync(path, "utf8")),
379
+ path,
380
+ );
381
+ if (report) reports.push(report);
382
+ } catch {
383
+ // Ignora findings ilegíveis — reconstrução best-effort.
384
+ }
385
+ }
386
+ if (reports.length === 0) return null;
387
+
388
+ return buildStateFromFindings(dedupeFindings(reports), { branch, readFile });
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+
392
+ export type FindingUpdate = {
393
+ decision?: FindingDecision;
394
+ comment?: string;
395
+ execState?: FindingExecState;
396
+ by?: string;
397
+ };
398
+
399
+ /** Aplica uma atualização a um finding (registrando histórico). */
400
+ export function applyFindingUpdate(
401
+ finding: FindingState,
402
+ update: FindingUpdate,
403
+ now: string = new Date().toISOString(),
404
+ ): FindingState {
405
+ if (update.decision && DECISION_SET.has(update.decision)) {
406
+ finding.decision = update.decision;
407
+ }
408
+ if (update.execState && EXEC_SET.has(update.execState)) {
409
+ finding.execState = update.execState;
410
+ }
411
+ if (update.comment !== undefined) finding.comment = update.comment;
412
+ finding.updatedAt = now;
413
+ finding.history.push({
414
+ at: now,
415
+ decision: finding.decision,
416
+ execState: finding.execState,
417
+ comment: finding.comment,
418
+ by: update.by,
419
+ });
420
+ return finding;
421
+ }
422
+
423
+ export type SyncResult = {
424
+ ok: number;
425
+ moved: string[];
426
+ stale: string[];
427
+ };
428
+
429
+ /**
430
+ * Reancora todos os findings presentes lendo os arquivos atuais. Findings cuja
431
+ * âncora sumiu viram `stale` (ou `done` se a decisão era `execute`).
432
+ * Muta o estado em memória (o caller persiste).
433
+ */
434
+ export function syncState(
435
+ state: FindingsStateFile,
436
+ readFile: FileReader,
437
+ now: string = new Date().toISOString(),
438
+ ): SyncResult {
439
+ const moved: string[] = [];
440
+ const stale: string[] = [];
441
+ let ok = 0;
442
+
443
+ for (const finding of Object.values(state.findings)) {
444
+ if (!finding.present) continue;
445
+ const content = readFile(finding.file);
446
+ if (content === null) {
447
+ if (finding.execState !== "done") finding.execState = "stale";
448
+ finding.updatedAt = now;
449
+ stale.push(finding.id);
450
+ continue;
451
+ }
452
+ const { status, line } = reanchor(content, finding.anchor, finding.line);
453
+ if (status === "stale") {
454
+ finding.execState =
455
+ finding.decision === "execute" && finding.execState === "pending"
456
+ ? "done"
457
+ : finding.execState === "done"
458
+ ? "done"
459
+ : "stale";
460
+ finding.updatedAt = now;
461
+ stale.push(finding.id);
462
+ continue;
463
+ }
464
+ if (status === "moved" && line !== null) {
465
+ finding.line = line;
466
+ if (finding.anchor) finding.anchor.line = line;
467
+ finding.updatedAt = now;
468
+ moved.push(finding.id);
469
+ continue;
470
+ }
471
+ ok++;
472
+ }
473
+
474
+ if (moved.length > 0 || stale.length > 0) state.updatedAt = now;
475
+ return { ok, moved, stale };
476
+ }
477
+
478
+ /** Nº de ocorrências da âncora na versão atual do arquivo (diagnóstico). */
479
+ export function occurrenceCount(
480
+ fileContent: string,
481
+ anchor: FindingAnchor | null,
482
+ ): number {
483
+ return locateAnchor(fileContent, anchor).occurrences;
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Store (I/O atômico)
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /** Persistência do `state.json` — escrita atômica (tmp + rename). */
491
+ export class FindingsStateStore {
492
+ private readonly path: string;
493
+ private readonly branch: string;
494
+
495
+ constructor(path: string, branch: string) {
496
+ this.path = path;
497
+ this.branch = branch;
498
+ }
499
+
500
+ /** Store para a run de uma branch. */
501
+ static at(artifactDir: string, branch: string): FindingsStateStore {
502
+ return new FindingsStateStore(stateFile(artifactDir, branch), branch);
503
+ }
504
+
505
+ get filePath(): string {
506
+ return this.path;
507
+ }
508
+
509
+ /** Lê o estado; null se ausente/inválido. */
510
+ read(): FindingsStateFile | null {
511
+ if (!existsSync(this.path)) return null;
512
+ try {
513
+ const raw = JSON.parse(readFileSync(this.path, "utf8")) as FindingsStateFile;
514
+ if (raw?.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
515
+ return raw;
516
+ } catch {
517
+ return null;
518
+ }
519
+ }
520
+
521
+ /** Grava o estado de forma atômica. */
522
+ write(state: FindingsStateFile): void {
523
+ mkdirSync(dirname(this.path), { recursive: true });
524
+ const tmp = `${this.path}.${process.pid}.tmp`;
525
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
526
+ renameSync(tmp, this.path);
527
+ }
528
+
529
+ /** Lê-modifica-grava; parte de um estado vazio se não existir. */
530
+ update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
531
+ const state = this.read() ?? emptyState(this.branch);
532
+ mutator(state);
533
+ state.updatedAt = new Date().toISOString();
534
+ reorder(state);
535
+ this.write(state);
536
+ return state;
537
+ }
538
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Barrel do módulo Jev (pré-análise do houndsman — Passo 1.5).
3
+ *
4
+ * Consumidores importam daqui (`"./jev"`). Tudo relacionado ao Jev vive nesta
5
+ * pasta — nada solto em `core/`.
6
+ */
7
+
8
+ export * from "./jev-candidates";
9
+ export * from "./jev-client";
10
+ export * from "./jev-detector";
11
+ export * from "./jev-questions";
12
+ export * from "./jev-types";