@payconductor/houndsman 0.3.0 → 0.5.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 (91) hide show
  1. package/README.md +25 -39
  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/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
@@ -5,7 +5,7 @@
5
5
  * **do usuário**:
6
6
  *
7
7
  * .houndsman/
8
- * ├── .gitignore AUTO — ignora core/ e .runs/ (gerados/temporários)
8
+ * ├── .gitignore AUTO — ignora core/ (gerado)
9
9
  * ├── core/types.ts AUTO — snapshot dos tipos + helpers
10
10
  * ├── core/skill/*.md AUTO — docs do skill (com {{projectName}} resolvido)
11
11
  * ├── watchdogs/*.ts USUÁRIO
@@ -16,13 +16,7 @@
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { basename, join } from "node:path";
18
18
  import { SKILL_DIR, SKILL_FILE } from "../assets";
19
- import {
20
- ARTIFACT_DIR,
21
- CORE_DIR,
22
- CORE_SKILL_DIR,
23
- OUTPUT_FORMAT_PATH,
24
- PROJECT_DIR,
25
- } from "../paths";
19
+ import { CORE_DIR, CORE_SKILL_DIR, OUTPUT_FORMAT_PATH, PROJECT_DIR } from "../paths";
26
20
 
27
21
  export type Harness = "agents" | "claude";
28
22
 
@@ -87,9 +81,9 @@ import type { HoundsmanConfig } from "./${CORE_DIR}/types";
87
81
  /**
88
82
  * Configuração do houndsman para ${projectName}.
89
83
  *
90
- * Caminhos são resolvidos pela camada: artefatos em ${ARTIFACT_DIR} e schema de
91
- * output em ${OUTPUT_FORMAT_PATH}. Ajuste watchdogs, exclusões e mapeamentos de
92
- * docs conforme o projeto.
84
+ * Runs, findings e artefatos vivem no storage global (SQLite por default) —
85
+ * fora do projeto. O schema de output fica em ${OUTPUT_FORMAT_PATH}. Ajuste
86
+ * watchdogs, exclusões e mapeamentos de docs conforme o projeto.
93
87
  */
94
88
  export const houndsmanConfig = {
95
89
  projectName: "${projectName}",
@@ -182,10 +176,16 @@ export type HoundsmanConcurrency = {
182
176
  jev?: number;
183
177
  };
184
178
 
179
+ /** Backend de persistência (reviews, findings e artefatos). */
180
+ export type HoundsmanStorage = {
181
+ provider?: "sqlite" | "file";
182
+ path?: string;
183
+ };
184
+
185
185
  /** Contrato de configuração do motor. */
186
186
  export type HoundsmanConfig<TId extends string = string> = {
187
187
  projectName: string;
188
- artifactDir?: string;
188
+ storage?: HoundsmanStorage;
189
189
  watchdogIds?: readonly TId[];
190
190
  watchdogs: Record<TId, WatchdogDefinition>;
191
191
  exclusions: readonly RegExp[];
@@ -267,10 +267,9 @@ export function isExcluded(filePath: string): boolean {
267
267
  const DOC_MAPPINGS_TEMPLATE = `/**
268
268
  * Mapeamento de padrões nos paths → documentação relevante.
269
269
  * Consumido pelo motor via \`HoundsmanConfig.docMappings\`.
270
- */
271
- export const DOC_MAPPINGS: readonly (readonly [RegExp, readonly string[]])[] = [
272
- [/^src\\//, ["AGENTS.md"]],
273
- ];
270
+ */\`core/\` é regenerado por \`houndsman update\`.
271
+ # Runs, findings e artefatos vivem no storage global (fora do projeto).
272
+ core
274
273
  `;
275
274
 
276
275
  const PROJECT_GITIGNORE = `# Diretórios gerados pelo houndsman — \`core/\` é regenerado por \`houndsman update\`
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Status da run (biblioteca) — leitura e heartbeat para a UI.
3
+ *
4
+ * `status ping` é o sinal de vida de uma skill em execução (revisão ou
5
+ * correção). O heartbeat é separado de `updatedAt`: triagem/sync atualizam o
6
+ * segundo, mas não fingem que existe uma execução em andamento.
7
+ */
8
+
9
+ import { currentBranch } from "../pr-context";
10
+ import { type RunStatus, touchRunStatus } from "../run-status";
11
+ import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
12
+
13
+ export type StatusResult = {
14
+ branch: string;
15
+ status: RunStatus | null;
16
+ startedAt?: string;
17
+ heartbeatAt?: string;
18
+ updatedAt: string;
19
+ };
20
+
21
+ function toResult(review: ReviewRecord): StatusResult {
22
+ return {
23
+ branch: review.branch,
24
+ status: review.status,
25
+ startedAt: review.startedAt,
26
+ heartbeatAt: review.heartbeatAt,
27
+ updatedAt: review.updatedAt,
28
+ };
29
+ }
30
+
31
+ /** Resolve a review da branch (explícita ou a atual do git). */
32
+ function findReview(
33
+ storage: StorageProvider,
34
+ repo: RepoIdentity,
35
+ branch?: string,
36
+ ): ReviewRecord {
37
+ const target = branch ?? currentBranch();
38
+ const review = storage.findReview(repo.key, target);
39
+ if (!review) {
40
+ throw new Error(`Nenhuma revisão para '${target}'.`);
41
+ }
42
+ return review;
43
+ }
44
+
45
+ /** Lê o status atual da run. */
46
+ export function readStatus(
47
+ storage: StorageProvider,
48
+ repo: RepoIdentity,
49
+ branch?: string,
50
+ ): StatusResult {
51
+ return toResult(findReview(storage, repo, branch));
52
+ }
53
+
54
+ /** Renova o heartbeat de uma execução ativa (`reviewing`/`fixing`). */
55
+ export function pingStatus(
56
+ storage: StorageProvider,
57
+ repo: RepoIdentity,
58
+ branch?: string,
59
+ now?: string,
60
+ ): StatusResult {
61
+ const review = findReview(storage, repo, branch);
62
+ return toResult(touchRunStatus(storage, review.id, now));
63
+ }
@@ -27,6 +27,20 @@ export type HoundsmanConcurrency = {
27
27
  jev?: number;
28
28
  };
29
29
 
30
+ /** Backend de persistência (reviews, findings e artefatos). */
31
+ export type HoundsmanStorage = {
32
+ /**
33
+ * Provider de storage (default: `sqlite`). `file` mantém o formato antigo
34
+ * em arquivos, mas sob o diretório global do houndsman.
35
+ */
36
+ provider?: "sqlite" | "file";
37
+ /**
38
+ * Caminho do banco (sqlite) ou raiz das runs (file). Default:
39
+ * `<HOUNDSMAN_HOME>/houndsman.db` / `<HOUNDSMAN_HOME>/runs`.
40
+ */
41
+ path?: string;
42
+ };
43
+
30
44
  /**
31
45
  * Configuração completa do motor de revisão para um projeto.
32
46
  *
@@ -35,8 +49,8 @@ export type HoundsmanConcurrency = {
35
49
  export type HoundsmanConfig<TId extends string = string> = {
36
50
  /** Nome legível do projeto (usado em prompts e docs). */
37
51
  projectName: string;
38
- /** Diretório dos artefatos (default: `ARTIFACT_DIR` da camada). */
39
- artifactDir?: string;
52
+ /** Persistência das runs e artefatos (default: SQLite global). */
53
+ storage?: HoundsmanStorage;
40
54
  /**
41
55
  * Ids dos watchdogs — **derivados** das chaves de `watchdogs` por
42
56
  * `defineHoundsmanConfig` (não informar manualmente).
@@ -72,11 +86,6 @@ export function defineHoundsmanConfig<TId extends string>(
72
86
  return { ...input, watchdogIds: Object.keys(input.watchdogs) as TId[] };
73
87
  }
74
88
 
75
- /** Diretório dos artefatos — o valor do projeto ou o default da camada. */
76
- export function resolveArtifactDir(config: HoundsmanConfig): string {
77
- return new ProjectConfig(config).artifactDir;
78
- }
79
-
80
89
  /**
81
90
  * Teto de arquivos por brief. Valor ausente/não-positivo cai no default —
82
91
  * mantém o contexto do subagente limitado em PRs grandes.
@@ -13,6 +13,9 @@ import { FileClassifier } from "../services/file-classifier";
13
13
  import { FixBatchBuilder } from "../services/fix-batch-builder";
14
14
  import { GitRepository } from "../services/git-repository";
15
15
  import { Verifier } from "../services/verifier";
16
+ import { createStorage } from "../storage/factory";
17
+ import { resolveRepoIdentity } from "../storage/repo-identity";
18
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
16
19
  import { ReviewSession } from "./review-session";
17
20
 
18
21
  /** Colaboradores injetáveis (testes/CLI). */
@@ -21,6 +24,8 @@ export type EngineDeps = {
21
24
  classifier?: FileClassifier;
22
25
  verifier?: Verifier;
23
26
  fixBatch?: FixBatchBuilder;
27
+ storage?: StorageProvider;
28
+ repo?: RepoIdentity;
24
29
  };
25
30
 
26
31
  /** Composition root do motor. */
@@ -30,6 +35,8 @@ export class HoundsmanEngine {
30
35
  readonly classifier: FileClassifier;
31
36
  readonly verifier: Verifier;
32
37
  readonly fixBatch: FixBatchBuilder;
38
+ readonly storage: StorageProvider;
39
+ readonly repo: RepoIdentity;
33
40
 
34
41
  constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
35
42
  this.project = new ProjectConfig(raw);
@@ -37,6 +44,8 @@ export class HoundsmanEngine {
37
44
  this.classifier = deps.classifier ?? new FileClassifier(raw);
38
45
  this.verifier = deps.verifier ?? new Verifier();
39
46
  this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
47
+ this.storage = deps.storage ?? createStorage(raw);
48
+ this.repo = deps.repo ?? resolveRepoIdentity(this.git);
40
49
  }
41
50
 
42
51
  /** Configuração crua (contrato do consumidor). */
@@ -46,6 +55,11 @@ export class HoundsmanEngine {
46
55
 
47
56
  /** Abre uma sessão de revisão (prepare/finalize). */
48
57
  session(options: ReviewOptions): ReviewSession {
49
- return ReviewSession.from(this.config, options);
58
+ return ReviewSession.from(this.config, options, this.storage, this.repo);
59
+ }
60
+
61
+ /** Libera o provider (fecha o SQLite e faz checkpoint do WAL). */
62
+ close(): void {
63
+ this.storage.close();
50
64
  }
51
65
  }
@@ -34,12 +34,21 @@ export type RunClassification = ClassificationResult & {
34
34
  carryover?: MergedFinding[];
35
35
  };
36
36
 
37
- /** Resultado da resolução de escopo. */
38
- export type ReviewScope = {
39
- classification: RunClassification;
40
- diff: DiffSource;
41
- reviewedFrom?: string;
42
- };
37
+ /** Resultado da resolução de escopo: revisar ou nada a fazer. */
38
+ export type ReviewScope =
39
+ | {
40
+ /** O `HEAD` é o mesmo commit já revisado — não há o que re-revisar. */
41
+ kind: "noop";
42
+ headSha: string;
43
+ /** Commit já coberto pelo review anterior. */
44
+ reviewedFrom: string;
45
+ }
46
+ | {
47
+ kind: "review";
48
+ classification: RunClassification;
49
+ diff: DiffSource;
50
+ reviewedFrom?: string;
51
+ };
43
52
 
44
53
  /** Decide entre review completo e incremental. */
45
54
  export class ReviewScopeResolver {
@@ -52,20 +61,29 @@ export class ReviewScopeResolver {
52
61
  }
53
62
 
54
63
  /**
64
+ * No-op quando o `HEAD` é exatamente o commit já revisado (sem
65
+ * `--full`/`--clean`): re-revisar a PR inteira seria desperdício, e o
66
+ * relatório anterior continua válido.
67
+ *
55
68
  * Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
56
69
  * sucede (sem rebase) e não veio `--full`/`--clean`.
57
70
  */
58
71
  resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
59
72
  const prevSha = previous?.reviewed?.headSha;
73
+
74
+ if (!this.options.full && !this.options.clean && prevSha === headSha) {
75
+ return { kind: "noop", headSha, reviewedFrom: headSha };
76
+ }
77
+
60
78
  if (
61
79
  this.options.full ||
62
80
  this.options.clean ||
63
81
  !prevSha ||
64
- prevSha === headSha ||
65
82
  !isAncestor(prevSha, headSha)
66
83
  ) {
67
84
  const diff = collectDiff(this.options.base, this.options.head);
68
85
  return {
86
+ kind: "review",
69
87
  diff,
70
88
  classification: {
71
89
  ...this.classifier
@@ -90,6 +108,6 @@ export class ReviewScopeResolver {
90
108
  reviewedFrom: prevSha,
91
109
  carryover: buildCarryover(previous, changedPaths),
92
110
  };
93
- return { classification, diff, reviewedFrom: prevSha };
111
+ return { kind: "review", classification, diff, reviewedFrom: prevSha };
94
112
  }
95
113
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Sessão de uma run (engine) — amarra opções, branch e layout.
2
+ * Sessão de uma run (engine) — amarra opções, branch, storage e staging.
3
3
  *
4
4
  * É o ponto de entrada do `prepare`/`finalize` para o CLI e para o engine.
5
5
  */
@@ -10,38 +10,66 @@ import {
10
10
  finalize,
11
11
  type PrepareResult,
12
12
  prepare,
13
+ type ReviewContext,
13
14
  type ReviewOptions,
14
15
  resolveBranch,
16
+ reviewContext,
15
17
  } from "../orchestrator";
16
- import { RunLayout } from "../run-layout";
18
+ import { sanitizeBranch } from "../report-builder";
19
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
17
20
 
18
21
  /** Uma revisão em andamento (prepare ou finalize). */
19
22
  export class ReviewSession {
20
23
  readonly config: HoundsmanConfig;
21
24
  readonly options: ReviewOptions;
22
25
  readonly branch: string;
23
- readonly layout: RunLayout;
26
+ readonly context: ReviewContext;
24
27
 
25
- constructor(config: HoundsmanConfig, options: ReviewOptions, branch: string) {
28
+ constructor(
29
+ config: HoundsmanConfig,
30
+ options: ReviewOptions,
31
+ branch: string,
32
+ storage: StorageProvider,
33
+ repo: RepoIdentity,
34
+ ) {
26
35
  this.config = config;
27
36
  this.options = options;
28
37
  this.branch = branch;
29
- this.layout = new RunLayout(options.dir, branch);
38
+ const review = options.finalize
39
+ ? storage.findReview(repo.key, branch)
40
+ : storage.getOrCreateReview({
41
+ repo,
42
+ branch,
43
+ slug: sanitizeBranch(branch),
44
+ base: options.base,
45
+ head: options.head,
46
+ });
47
+ if (!review) {
48
+ throw new Error(
49
+ `Nenhuma revisão para '${branch}'. Rode \`houndsman\` (preparar) primeiro.`,
50
+ );
51
+ }
52
+ this.context = reviewContext(storage, review);
30
53
  }
31
54
 
32
55
  /** Resolve a branch (opção explícita ou a atual do git). */
33
- static from(config: HoundsmanConfig, options: ReviewOptions): ReviewSession {
34
- return new ReviewSession(config, options, resolveBranch(options));
56
+ static from(
57
+ config: HoundsmanConfig,
58
+ options: ReviewOptions,
59
+ storage: StorageProvider,
60
+ repo: RepoIdentity,
61
+ ): ReviewSession {
62
+ return new ReviewSession(config, options, resolveBranch(options), storage, repo);
35
63
  }
36
64
 
37
65
  /** Passo 1 — preparar. */
38
66
  prepare(): Promise<PrepareResult> {
39
- return prepare(this.options, this.branch, this.config, this.layout);
67
+ return prepare(this.options, this.branch, this.config, this.context);
40
68
  }
41
69
 
42
70
  /** Passo 3 — finalizar. */
43
71
  finalize(): FinalizeResult {
44
- return finalize(this.options, this.branch, this.config, this.layout);
72
+ return finalize(this.options, this.branch, this.config, this.context);
45
73
  }
46
74
 
47
75
  /** Executa o passo escolhido em `--finalize`. */
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * Estado de triagem dos findings (biblioteca).
3
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.
4
+ * O dump (`state.json`) é um detalhe do provider de arquivos; no SQLite o
5
+ * estado é reconstruído de `findings` + `finding_history`. Cada finding tem um
6
+ * `id` curto e um `fingerprint` de conteúdo: nas re-execuções do `--finalize`
7
+ * o estado é preservado casando por fingerprint.
8
8
  *
9
9
  * A `line` é cache — `syncState` a recalcula após edições (auto-fix) e marca
10
10
  * `stale` quando a âncora some.
11
11
  */
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
- import { existsSync, readFileSync } from "node:fs";
15
- import { join } from "node:path";
16
14
  import {
17
15
  buildAnchor,
18
16
  type FindingAnchor,
@@ -20,14 +18,7 @@ import {
20
18
  locateAnchor,
21
19
  reanchor,
22
20
  } from "./finding-anchor";
23
- import {
24
- dedupeFindings,
25
- type MergedFinding,
26
- parseWatchdogReport,
27
- sanitizeBranch,
28
- type WatchdogReport,
29
- } from "./report-builder";
30
- import { RunLayout } from "./run-layout";
21
+ import { type MergedFinding, sanitizeBranch } from "./report-builder";
31
22
  import { systemClock } from "./services/clock";
32
23
  import { type FileReader, ProjectFiles } from "./services/project-files";
33
24
  import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
@@ -85,6 +76,10 @@ export type GitHubLink = {
85
76
  pr: number;
86
77
  /** Quando foi enviado (ISO). */
87
78
  at: string;
79
+ /** Node ID da thread de revisão (GraphQL) — base para resolver a conversa. */
80
+ threadId?: string;
81
+ /** Quando a thread foi marcada como resolvida no GitHub (ISO). */
82
+ resolvedAt?: string;
88
83
  };
89
84
 
90
85
  /** Marco da revisão que originou o estado: commit revisado + quando. */
@@ -142,16 +137,6 @@ export const FINDINGS_STATE_VERSION = 2;
142
137
  // Caminhos
143
138
  // ---------------------------------------------------------------------------
144
139
 
145
- /** Diretório de artefatos da run (branch-slug). */
146
- export function runDir(artifactDir: string, branch: string): string {
147
- return new RunLayout(artifactDir, branch).outDir;
148
- }
149
-
150
- /** Caminho do `state.json` da run. */
151
- export function stateFile(artifactDir: string, branch: string): string {
152
- return new RunLayout(artifactDir, branch).stateFile;
153
- }
154
-
155
140
  /** Leitor padrão a partir do cwd (null se ausente/ilegível). */
156
141
  export function fileReader(cwd: string): FileReader {
157
142
  return new ProjectFiles(cwd).reader();
@@ -264,6 +249,32 @@ export function filterFindings(
264
249
  .sort(compareFindings);
265
250
  }
266
251
 
252
+ /** Resultado da resolução de um id (completo ou prefixo único). */
253
+ export type FindingIdResolution =
254
+ | { status: "found"; id: string }
255
+ | { status: "ambiguous"; matches: string[] }
256
+ | { status: "missing" };
257
+
258
+ /**
259
+ * Resolve um id de finding: exato primeiro, depois prefixo único.
260
+ * Ambíguo devolve os candidatos para a mensagem de erro.
261
+ */
262
+ export function resolveFindingId(
263
+ state: FindingsStateFile,
264
+ input: string,
265
+ ): FindingIdResolution {
266
+ const needle = input.trim();
267
+ if (needle.length === 0) return { status: "missing" };
268
+ if (state.findings[needle]) return { status: "found", id: needle };
269
+
270
+ const matches = Object.keys(state.findings).filter((id) =>
271
+ id.toLowerCase().startsWith(needle.toLowerCase()),
272
+ );
273
+ if (matches.length === 1) return { status: "found", id: matches[0] };
274
+ if (matches.length > 1) return { status: "ambiguous", matches };
275
+ return { status: "missing" };
276
+ }
277
+
267
278
  // ---------------------------------------------------------------------------
268
279
  // Construção (finalize)
269
280
  // ---------------------------------------------------------------------------
@@ -276,6 +287,12 @@ export type BuildStateOptions = {
276
287
  reviewed?: FindingsReviewed;
277
288
  /** SHA usado para carimbar `foundAtSha` de findings novos/re-reportados. */
278
289
  headSha?: string;
290
+ /**
291
+ * Arquivos efetivamente revisados neste ciclo (união de `reviewedFiles`).
292
+ * Quando presente, só findings ausentes desses arquivos viram `done` — o que
293
+ * ficou fora do orçamento/skipped não pode ser dado como resolvido.
294
+ */
295
+ reviewedPaths?: ReadonlySet<string>;
279
296
  now?: string;
280
297
  };
281
298
 
@@ -296,14 +313,21 @@ export function buildStateFromFindings(
296
313
 
297
314
  const result: Record<string, FindingState> = {};
298
315
  // 1. Preserva o estado anterior (marca como ausente até provar o contrário).
316
+ const reviewedPaths = options.reviewedPaths;
299
317
  for (const prev of Object.values(previous?.findings ?? {})) {
318
+ // Ausente do novo relatório ⇒ corrigido manualmente entre revisões (ou
319
+ // some junto com o trecho): vira `done`. `ignore` é decisão explícita de
320
+ // não corrigir — preserva o estado original. Arquivo fora da revisão
321
+ // (orçamento/skipped) também preserva: ausência não prova resolução.
322
+ const wasReviewed = !reviewedPaths || reviewedPaths.has(prev.file);
323
+ const resolvedByReview =
324
+ wasReviewed && prev.present && prev.decision !== "ignore";
325
+ const queuedAndGone =
326
+ wasReviewed && prev.decision === "execute" && prev.execState === "pending";
300
327
  result[prev.id] = {
301
328
  ...prev,
302
329
  present: false,
303
- execState:
304
- prev.execState === "pending" && prev.decision === "execute"
305
- ? "done"
306
- : prev.execState,
330
+ execState: resolvedByReview || queuedAndGone ? "done" : prev.execState,
307
331
  updatedAt: now,
308
332
  };
309
333
  }
@@ -377,47 +401,6 @@ export function buildStateFromFindings(
377
401
  return state;
378
402
  }
379
403
 
380
- /**
381
- * Reconstrói o estado a partir dos artefatos da run (`classification.json` +
382
- * `findings/<id>.json`), sem exigir `--finalize`. Devolve null se não houver
383
- * findings gravados.
384
- */
385
- export function buildStateFromRunDir(
386
- outDir: string,
387
- branch: string,
388
- readFile: FileReader,
389
- ): FindingsStateFile | null {
390
- const classificationPath = join(outDir, "classification.json");
391
- if (!existsSync(classificationPath)) return null;
392
-
393
- let activeIds: string[] = [];
394
- try {
395
- const classification = JSON.parse(readFileSync(classificationPath, "utf8")) as {
396
- watchdogs?: Record<string, string[]>;
397
- };
398
- activeIds = Object.keys(classification.watchdogs ?? {});
399
- } catch {
400
- return null;
401
- }
402
-
403
- const reports: WatchdogReport[] = [];
404
- for (const id of activeIds) {
405
- const path = join(outDir, "findings", `${id}.json`);
406
- if (!existsSync(path)) continue;
407
- try {
408
- const report = parseWatchdogReport(
409
- JSON.parse(readFileSync(path, "utf8")),
410
- path,
411
- );
412
- if (report) reports.push(report);
413
- } catch {
414
- // Ignora findings ilegíveis — reconstrução best-effort.
415
- }
416
- }
417
- if (reports.length === 0) return null;
418
-
419
- return buildStateFromFindings(dedupeFindings(reports), { branch, readFile });
420
- }
421
404
  // ---------------------------------------------------------------------------
422
405
 
423
406
  export type FindingUpdate = {
@@ -574,7 +557,7 @@ export class Finding {
574
557
  }
575
558
  }
576
559
 
577
- /** Coleção de triagem de uma run — superfície OO sobre o `state.json`. */
560
+ /** Coleção de triagem de uma run — superfície OO sobre o dump persistido. */
578
561
  export class FindingsState {
579
562
  readonly file: FindingsStateFile;
580
563
 
@@ -671,11 +654,6 @@ export class FindingsRepository {
671
654
  this.branch = branch;
672
655
  }
673
656
 
674
- /** Repository para a run de uma branch. */
675
- static at(artifactDir: string, branch: string): FindingsRepository {
676
- return new FindingsRepository(stateFile(artifactDir, branch), branch);
677
- }
678
-
679
657
  get filePath(): string {
680
658
  return this.path;
681
659
  }