@payconductor/houndsman 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +39 -78
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +89 -9
  6. package/src/core/assets.ts +8 -0
  7. package/src/core/brief.ts +25 -2
  8. package/src/core/commands/findings.ts +390 -0
  9. package/src/core/commands/scaffold.ts +20 -0
  10. package/src/core/config.ts +42 -0
  11. package/src/core/diff-source.ts +140 -0
  12. package/src/core/file-classifier.ts +50 -2
  13. package/src/core/finding-anchor.ts +147 -0
  14. package/src/core/findings-state.ts +538 -0
  15. package/src/core/jev/jev-client.ts +72 -11
  16. package/src/core/jev/jev-detector.ts +21 -19
  17. package/src/core/orchestrator.ts +169 -17
  18. package/src/core/paths.ts +3 -0
  19. package/src/core/registry-check.ts +15 -0
  20. package/src/core/report-builder.ts +113 -9
  21. package/src/core/types.ts +10 -0
  22. package/src/index.ts +78 -2
  23. package/src/server/artifacts.ts +66 -0
  24. package/src/server/client/App.tsx +158 -0
  25. package/src/server/client/ArtifactsPanel.tsx +48 -0
  26. package/src/server/client/Filters.tsx +83 -0
  27. package/src/server/client/FindingCard.tsx +122 -0
  28. package/src/server/client/FindingsList.tsx +54 -0
  29. package/src/server/client/RunList.tsx +43 -0
  30. package/src/server/client/SummaryBar.tsx +21 -0
  31. package/src/server/client/api.ts +37 -0
  32. package/src/server/client/constants.ts +63 -0
  33. package/src/server/client/index.tsx +12 -0
  34. package/src/server/client/types.ts +48 -0
  35. package/src/server/client-build.ts +47 -0
  36. package/src/server/index.ts +365 -0
  37. package/src/skill/README.md +14 -6
  38. package/src/skill/SETUP.md +5 -3
  39. package/src/skill/houndsman-update.md +1 -1
  40. package/src/skill/houndsman.md +87 -10
  41. package/src/tests/findings-anchor.test.ts +120 -0
  42. package/src/tests/findings-commands.test.ts +226 -0
  43. package/src/tests/findings-state.test.ts +274 -0
  44. package/src/tests/review-scripts.test.ts +299 -0
  45. package/src/tests/server.test.ts +212 -0
@@ -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
+ }
@@ -17,16 +17,27 @@ import type { JevEvaluateRequest, JevEvaluateResponse } from "./jev-types";
17
17
  const DEFAULT_BASE_URL = "https://api.typesafe.ai";
18
18
  const DEFAULT_MODEL = "jev-latest";
19
19
  const DEFAULT_TIMEOUT_MS = 20_000;
20
- /** Caminho fixo do endpoint System One. */
20
+ /** Timeout da verificação de credencial do `doctor` (mais curto que o de review). */
21
+ const DEFAULT_PING_TIMEOUT_MS = 10_000;
22
+ /** Caminho fixo do endpoinft System One. */
21
23
  const JEV_PATH = "/v1/systemone";
22
24
 
23
25
  export class JevError extends Error {
24
- constructor(message: string) {
26
+ /** Status HTTP da resposta, quando houver (indefinido em falha de rede). */
27
+ readonly status?: number;
28
+
29
+ constructor(message: string, status?: number) {
25
30
  super(message);
26
31
  this.name = "JevError";
32
+ this.status = status;
27
33
  }
28
34
  }
29
35
 
36
+ /** Resultado da verificação de credencial (`doctor`) — não lança, devolve dados. */
37
+ export type JevCredentialCheck =
38
+ | { ok: true; model: string }
39
+ | { ok: false; kind: "invalid" | "unreachable"; detail: string };
40
+
30
41
  export type JevClientOptions = {
31
42
  /** Chave de API (default: `process.env.TYPESAFE_API_KEY`). */
32
43
  apiKey?: string;
@@ -78,22 +89,72 @@ export class JevClient {
78
89
  );
79
90
  return data;
80
91
  } catch (error) {
81
- throw new JevError(JevClient.describeError(error));
92
+ const { message, status } = JevClient.parseError(error);
93
+ throw new JevError(message, status);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Verifica a credencial com uma chamada mínima (usada pelo `doctor`).
99
+ *
100
+ * Não lança: devolve o resultado tipado para o chamador decidir (fail-open).
101
+ * 2xx → válida; 401/403 → inválida; demais → indeterminada (rede/timeout).
102
+ */
103
+ async validateCredential(): Promise<JevCredentialCheck> {
104
+ if (!this.apiKey) {
105
+ return {
106
+ ok: false,
107
+ kind: "unreachable",
108
+ detail: "TYPESAFE_API_KEY não configurada.",
109
+ };
110
+ }
111
+ const request: JevEvaluateRequest = {
112
+ state: "Ping de verificação de credencial do houndsman.",
113
+ questions: {
114
+ ping: {
115
+ type: "noul",
116
+ instructions: 'A seguinte afirmação é verdadeira? "Este é um ping."',
117
+ },
118
+ },
119
+ };
120
+ try {
121
+ const { data } = await this.http.post<JevEvaluateResponse>(
122
+ JEV_PATH,
123
+ { ...request, model: this.model },
124
+ {
125
+ headers: { Authorization: `Bearer ${this.apiKey}` },
126
+ timeout: DEFAULT_PING_TIMEOUT_MS,
127
+ },
128
+ );
129
+ return { ok: true, model: data.model ?? this.model };
130
+ } catch (error) {
131
+ const { message, status } = JevClient.parseError(error);
132
+ const kind = status === 401 || status === 403 ? "invalid" : "unreachable";
133
+ return { ok: false, kind, detail: message };
82
134
  }
83
135
  }
84
136
 
85
- /** Normaliza o erro do axios em mensagem legível (sem expor o payload bruto). */
86
- private static describeError(error: unknown): string {
137
+ /** Normaliza o erro do axios em `{ message, status }` (sem expor o payload bruto). */
138
+ private static parseError(error: unknown): {
139
+ message: string;
140
+ status?: number;
141
+ } {
87
142
  if (isAxiosError(error)) {
88
143
  const status = error.response?.status;
144
+ const responseData = error.response?.data;
89
145
  const detail =
90
- typeof error.response?.data === "string"
91
- ? error.response.data
92
- : error.response?.data
93
- ? JSON.stringify(error.response.data)
146
+ typeof responseData === "string"
147
+ ? responseData
148
+ : responseData
149
+ ? responseData?.detail?.message || JSON.stringify(responseData)
94
150
  : error.message;
95
- return status ? `HTTP ${status}: ${detail}` : detail;
151
+ return {
152
+ message: status ? `HTTP ${status}: ${detail}` : detail,
153
+ status,
154
+ };
96
155
  }
97
- return error instanceof Error ? error.message : String(error);
156
+ return {
157
+ message: error instanceof Error ? error.message : String(error),
158
+ };
98
159
  }
99
160
  }