@payconductor/houndsman 0.1.0 → 0.1.3

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 (61) hide show
  1. package/README.md +68 -89
  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 +101 -10
  6. package/src/core/assets.ts +11 -0
  7. package/src/core/brief.ts +27 -5
  8. package/src/core/commands/findings.ts +618 -0
  9. package/src/core/commands/review.ts +2 -0
  10. package/src/core/commands/scaffold.ts +33 -44
  11. package/src/core/config.ts +42 -0
  12. package/src/core/diff-source.ts +140 -0
  13. package/src/core/file-classifier.ts +50 -2
  14. package/src/core/finding-anchor.ts +147 -0
  15. package/src/core/findings-state.ts +556 -0
  16. package/src/core/fix-batch.ts +236 -0
  17. package/src/core/git-revision.ts +105 -0
  18. package/src/core/jev/index.ts +1 -0
  19. package/src/core/jev/jev-client.ts +72 -11
  20. package/src/core/jev/jev-detector.ts +21 -19
  21. package/src/core/jev/jev-verify.ts +178 -0
  22. package/src/core/orchestrator.ts +362 -23
  23. package/src/core/paths.ts +9 -0
  24. package/src/core/registry-check.ts +15 -0
  25. package/src/core/report-builder.ts +118 -9
  26. package/src/core/run-status.ts +123 -0
  27. package/src/core/types.ts +10 -0
  28. package/src/core/verify.ts +175 -0
  29. package/src/core/watchdog-registry.ts +1 -2
  30. package/src/index.ts +78 -2
  31. package/src/server/artifacts.ts +67 -0
  32. package/src/server/client/App.tsx +158 -0
  33. package/src/server/client/ArtifactsPanel.tsx +48 -0
  34. package/src/server/client/Filters.tsx +83 -0
  35. package/src/server/client/FindingCard.tsx +122 -0
  36. package/src/server/client/FindingsList.tsx +54 -0
  37. package/src/server/client/RunList.tsx +52 -0
  38. package/src/server/client/SummaryBar.tsx +21 -0
  39. package/src/server/client/api.ts +37 -0
  40. package/src/server/client/constants.ts +94 -0
  41. package/src/server/client/index.tsx +12 -0
  42. package/src/server/client/types.ts +52 -0
  43. package/src/server/client-build.ts +47 -0
  44. package/src/server/index.ts +376 -0
  45. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  46. package/src/skill/README.md +43 -11
  47. package/src/skill/SETUP.md +16 -4
  48. package/src/skill/SKILL.md +48 -0
  49. package/src/skill/fix.md +206 -0
  50. package/src/skill/{houndsman.md → hunt.md} +83 -33
  51. package/src/skill/{houndsman-update.md → train.md} +12 -12
  52. package/src/skill/verify.md +70 -0
  53. package/src/tests/findings-anchor.test.ts +120 -0
  54. package/src/tests/findings-commands.test.ts +476 -0
  55. package/src/tests/findings-state.test.ts +325 -0
  56. package/src/tests/fix-batch.test.ts +257 -0
  57. package/src/tests/review-scripts.test.ts +299 -0
  58. package/src/tests/run-status.test.ts +147 -0
  59. package/src/tests/scaffold.test.ts +59 -0
  60. package/src/tests/server.test.ts +242 -0
  61. package/src/tests/verify.test.ts +170 -0
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { basename, join } from "node:path";
18
- import { SKILL_DIR } from "../assets";
18
+ import { SKILL_DIR, SKILL_FILE } from "../assets";
19
19
  import {
20
20
  ARTIFACT_DIR,
21
21
  CORE_DIR,
@@ -164,6 +164,24 @@ export type FileStatus =
164
164
 
165
165
  export type FileStatusMap = Record<string, { status: FileStatus; oldPath?: string }>;
166
166
 
167
+ /** Linhas adicionadas/removidas de um arquivo no diff (churn). */
168
+ export type FileChurn = { additions: number; deletions: number };
169
+
170
+ /** Churn por caminho de arquivo. */
171
+ export type ChurnMap = Record<string, FileChurn>;
172
+
173
+ /** Limites de triage para PRs grandes. */
174
+ export type HoundsmanBudgets = {
175
+ /** Máx. de arquivos por brief (default: 40). Excedente vai para "fora do orçamento". */
176
+ maxFilesPerBrief?: number;
177
+ };
178
+
179
+ /** Concorrência das fases paralelas internas. */
180
+ export type HoundsmanConcurrency = {
181
+ /** Chamadas Jev simultâneas (default: 5). */
182
+ jev?: number;
183
+ };
184
+
167
185
  /** Contrato de configuração do motor. */
168
186
  export type HoundsmanConfig<TId extends string = string> = {
169
187
  projectName: string;
@@ -173,6 +191,8 @@ export type HoundsmanConfig<TId extends string = string> = {
173
191
  exclusions: readonly RegExp[];
174
192
  docMappings: readonly (readonly [RegExp, readonly string[]])[];
175
193
  alwaysIncludeDocs?: readonly string[];
194
+ budgets?: HoundsmanBudgets;
195
+ concurrency?: HoundsmanConcurrency;
176
196
  };
177
197
 
178
198
  /** Helper de autoria (identidade) — apenas tipagem. */
@@ -259,25 +279,6 @@ core/
259
279
  .runs/
260
280
  `;
261
281
 
262
- const SKILL_POINTER = (
263
- name: string,
264
- projectName: string,
265
- canonical: string,
266
- description: string,
267
- argumentHint: string,
268
- ): string => `---
269
- name: ${name}
270
- description: '${description}'
271
- argument-hint: '${argumentHint}'
272
- ---
273
-
274
- # ${name} — ${projectName}
275
-
276
- > **Ponteiro.** O conteúdo canônico está em \`${canonical}\`.
277
-
278
- Leia e siga \`${canonical}\`.
279
- `;
280
-
281
282
  // ---------------------------------------------------------------------------
282
283
  // Escrita
283
284
  // ---------------------------------------------------------------------------
@@ -325,15 +326,17 @@ export function writeUserLayer(ctx: ScaffoldContext): void {
325
326
 
326
327
  /**
327
328
  * Arquivos auto-gerados: tipos, docs do skill, `.gitignore` (ignora `core/` e
328
- * `.runs/`) e os ponteiros de skill dos harnesses. Sempre sobrescritos (é o
329
- * alvo do `update`).
329
+ * `.runs/`) e o `SKILL.md` de cada harness. Sempre sobrescritos (é o alvo do
330
+ * `update`).
330
331
  */
331
332
  export function syncGeneratedFiles(ctx: ScaffoldContext): void {
332
333
  writeGeneratedFile(ctx, join(CORE_DIR, "types.ts"), TYPES_TEMPLATE);
333
334
  writeGeneratedFile(ctx, join(PROJECT_DIR, ".gitignore"), PROJECT_GITIGNORE);
334
335
 
336
+ // Docs do skill vão para `.houndsman/core/skill/`. O `SKILL.md` é o entry point
337
+ // dos harnesses (escrito em `writeHarnessPointers`), não um doc da camada.
335
338
  for (const name of readdirSync(SKILL_DIR)) {
336
- if (!name.endsWith(".md")) continue;
339
+ if (!name.endsWith(".md") || name === SKILL_FILE) continue;
337
340
  const raw = readFileSync(join(SKILL_DIR, name), "utf8");
338
341
  writeGeneratedFile(
339
342
  ctx,
@@ -345,29 +348,15 @@ export function syncGeneratedFiles(ctx: ScaffoldContext): void {
345
348
  for (const harness of ctx.harnesses) writeHarnessPointers(ctx, harness);
346
349
  }
347
350
 
348
- /** Escreve os ponteiros de skill de um harness. */
351
+ /**
352
+ * Escreve o `SKILL.md` do harness a partir do asset base (`src/skill/SKILL.md`),
353
+ * resolvendo `{{projectName}}`. O texto do skill vive no asset — não aqui.
354
+ */
349
355
  function writeHarnessPointers(ctx: ScaffoldContext, harness: Harness): void {
350
- const pointerDir = `.${harness}/skills`;
351
- writeGeneratedFile(
352
- ctx,
353
- join(pointerDir, "houndsman", "SKILL.md"),
354
- SKILL_POINTER(
355
- "houndsman",
356
- ctx.projectName,
357
- `${CORE_SKILL_DIR}/houndsman.md`,
358
- "Revisa Pull Requests com watchdogs especializados por domínio. Orquestra `bun run houndsman context` → `bun run houndsman` → subagentes → `bun run houndsman --finalize`. Config em `houndsman.config.ts`; regras em `.houndsman/`; relatório em `.houndsman/.runs/<branch-slug>/review.md`.",
359
- "Nome da branch (opcional, detecta automaticamente se omitido)",
360
- ),
361
- );
356
+ const raw = readFileSync(join(SKILL_DIR, SKILL_FILE), "utf8");
362
357
  writeGeneratedFile(
363
358
  ctx,
364
- join(pointerDir, "houndsman-update", "SKILL.md"),
365
- SKILL_POINTER(
366
- "houndsman-update",
367
- ctx.projectName,
368
- `${CORE_SKILL_DIR}/houndsman-update.md`,
369
- "Atualiza as regras e protocolos do skill houndsman (watchdogs, exclusões, mapeamentos de docs).",
370
- "Watchdog alvo (opcional)",
371
- ),
359
+ join(`.${harness}`, "skills", "houndsman", SKILL_FILE),
360
+ raw.replaceAll("{{projectName}}", ctx.projectName),
372
361
  );
373
362
  }
@@ -12,6 +12,29 @@ import type { WatchdogDefinition } from "./types";
12
12
  /** Documentos sempre incluídos nos briefs (default). */
13
13
  export const DEFAULT_ALWAYS_INCLUDE_DOCS: readonly string[] = ["AGENTS.md"];
14
14
 
15
+ /**
16
+ * Teto default de arquivos por brief. Um PR grande (milhares de linhas) pode
17
+ * concentrar centenas de arquivos em um único domínio; o teto mantém o contexto
18
+ * do subagente limitado — o excedente vira "fora do orçamento" (cobertura
19
+ * honesta), nunca some silenciosamente.
20
+ */
21
+ export const DEFAULT_MAX_FILES_PER_BRIEF = 40;
22
+
23
+ /** Concorrência default das chamadas simultâneas ao Jev. */
24
+ export const DEFAULT_JEV_CONCURRENCY = 5;
25
+
26
+ /** Limites de triage para PRs grandes. */
27
+ export type HoundsmanBudgets = {
28
+ /** Máx. de arquivos por brief (default: 40). Excedente vai para "fora do orçamento". */
29
+ maxFilesPerBrief?: number;
30
+ };
31
+
32
+ /** Concorrência das fases paralelas internas. */
33
+ export type HoundsmanConcurrency = {
34
+ /** Chamadas Jev simultâneas (default: 5). */
35
+ jev?: number;
36
+ };
37
+
15
38
  /**
16
39
  * Configuração completa do motor de revisão para um projeto.
17
40
  *
@@ -35,6 +58,10 @@ export type HoundsmanConfig<TId extends string = string> = {
35
58
  docMappings: readonly (readonly [RegExp, readonly string[]])[];
36
59
  /** Docs sempre incluídos nos briefs (default: `DEFAULT_ALWAYS_INCLUDE_DOCS`). */
37
60
  alwaysIncludeDocs?: readonly string[];
61
+ /** Limites de triage para PRs grandes (default: `DEFAULT_MAX_FILES_PER_BRIEF`). */
62
+ budgets?: HoundsmanBudgets;
63
+ /** Concorrência das fases paralelas internas. */
64
+ concurrency?: HoundsmanConcurrency;
38
65
  };
39
66
 
40
67
  /** Entrada de `defineHoundsmanConfig` — `watchdogIds` é derivado, não informado. */
@@ -57,3 +84,18 @@ export function defineHoundsmanConfig<TId extends string>(
57
84
  export function resolveArtifactDir(config: HoundsmanConfig): string {
58
85
  return config.artifactDir ?? ARTIFACT_DIR;
59
86
  }
87
+
88
+ /**
89
+ * Teto de arquivos por brief. Valor ausente/não-positivo cai no default —
90
+ * mantém o contexto do subagente limitado em PRs grandes.
91
+ */
92
+ export function resolveMaxFilesPerBrief(config: HoundsmanConfig): number {
93
+ const value = config.budgets?.maxFilesPerBrief;
94
+ return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
95
+ }
96
+
97
+ /** Concorrência das chamadas Jev (valor ausente/não-positivo cai no default). */
98
+ export function resolveJevConcurrency(config: HoundsmanConfig): number {
99
+ const value = config.concurrency?.jev;
100
+ return value !== undefined && value > 0 ? value : DEFAULT_JEV_CONCURRENCY;
101
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Coleta do diff em um único passo (biblioteca).
3
+ *
4
+ * Roda `git diff base...head` **uma vez** (patch) e `--numstat` **uma vez**
5
+ * (churn), fatiando o patch por arquivo em memória — o custo não cresce com o
6
+ * número de arquivos da PR.
7
+ *
8
+ * Usado por `orchestrator.prepare` (churn para a classificação + patch para o
9
+ * Jev) e por `classifyRange` (fallback standalone).
10
+ */
11
+
12
+ import type { ChurnMap, FileChurn } from "./types";
13
+
14
+ export type DiffSource = {
15
+ /** path → patch do arquivo (header + hunks), truncado por `maxPerFileChars`. */
16
+ byFile: Map<string, string>;
17
+ /** path (novo) → linhas adicionadas/removidas. */
18
+ churn: Map<string, FileChurn>;
19
+ };
20
+
21
+ export type CollectDiffOptions = {
22
+ /** Teto de caracteres por arquivo no patch (default: 8_000). */
23
+ maxPerFileChars?: number;
24
+ };
25
+
26
+ /** Teto default de caracteres por arquivo no patch (compartilhado com o git-revision). */
27
+ export const DEFAULT_MAX_PER_FILE_CHARS = 8_000;
28
+
29
+ /** Executa `git` com `core.quotepath=false` (paths literais) e devolve o stdout. */
30
+ function runGit(args: string[]): string {
31
+ const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ if (result.exitCode !== 0) {
36
+ const stderr = new TextDecoder().decode(result.stderr).trim();
37
+ throw new Error(`git ${args.join(" ")} falhou: ${stderr}`);
38
+ }
39
+ return new TextDecoder().decode(result.stdout);
40
+ }
41
+
42
+ /** Novo caminho do arquivo a partir do cabeçalho de um segmento do patch. */
43
+ function extractPath(segment: string): string | undefined {
44
+ // Só o cabeçalho (antes do primeiro hunk) pode conter as linhas `---`/`+++` —
45
+ // assim uma linha de conteúdo como `+++ b/x` não é confundida com o header.
46
+ const hunkAt = segment.indexOf("\n@@");
47
+ const header = hunkAt >= 0 ? segment.slice(0, hunkAt) : segment;
48
+ const lines = header.split("\n");
49
+
50
+ for (const line of lines) {
51
+ if (line.startsWith("+++ b/")) return line.slice(6);
52
+ }
53
+ for (const line of lines) {
54
+ // Deletados têm `+++ /dev/null`; o caminho antigo é a melhor referência.
55
+ if (line.startsWith("--- a/")) return line.slice(6);
56
+ }
57
+
58
+ // Fallback (ex.: binário, sem `---`/`+++`): `a/<old> b/<new>`.
59
+ const firstLine = lines[0] ?? "";
60
+ const marker = firstLine.lastIndexOf(" b/");
61
+ return marker >= 0 ? firstLine.slice(marker + 3) : undefined;
62
+ }
63
+
64
+ /** Fatia um patch unificado por arquivo (`diff --git`). */
65
+ export function parseDiffIndex(
66
+ patch: string,
67
+ maxPerFileChars = Number.POSITIVE_INFINITY,
68
+ ): Map<string, string> {
69
+ const map = new Map<string, string>();
70
+ for (const segment of patch.split(/^diff --git /m)) {
71
+ if (!segment.trim()) continue;
72
+ const path = extractPath(segment);
73
+ if (!path) continue;
74
+ const text = `diff --git ${segment}`;
75
+ map.set(
76
+ path,
77
+ text.length > maxPerFileChars ? text.slice(0, maxPerFileChars) : text,
78
+ );
79
+ }
80
+ return map;
81
+ }
82
+
83
+ /** Contagem do `--numstat` (`-` em binários) → número. */
84
+ function toCount(value: string): number {
85
+ const parsed = Number.parseInt(value, 10);
86
+ return Number.isNaN(parsed) ? 0 : parsed;
87
+ }
88
+
89
+ /** Fatia `git diff --numstat -z` em path → churn (trata renames/cópias/bins). */
90
+ export function parseNumstat(raw: string): Map<string, FileChurn> {
91
+ const map = new Map<string, FileChurn>();
92
+ const tokens = raw.split("\0");
93
+
94
+ for (let i = 0; i < tokens.length; i++) {
95
+ const token = tokens[i];
96
+ if (!token) continue;
97
+ const fields = token.split("\t");
98
+ if (fields.length < 3) continue;
99
+
100
+ let path = fields[2];
101
+ if (path === "") {
102
+ // Renomeado/copiado: os dois caminhos seguem como tokens próprios.
103
+ path = tokens[i + 2] ?? "";
104
+ i += 2;
105
+ }
106
+ if (!path) continue;
107
+
108
+ map.set(path, { additions: toCount(fields[0]), deletions: toCount(fields[1]) });
109
+ }
110
+ return map;
111
+ }
112
+
113
+ /** Churn por arquivo (`git diff --numstat -z`) — 1 spawn. */
114
+ export function collectChurn(base: string, head: string): Map<string, FileChurn> {
115
+ return parseNumstat(runGit(["diff", "--numstat", "-z", `${base}...${head}`]));
116
+ }
117
+
118
+ /**
119
+ * Patch + churn em uma passada: 2 spawns fixos, independentes do nº de arquivos.
120
+ */
121
+ export function collectDiff(
122
+ base: string,
123
+ head: string,
124
+ options: CollectDiffOptions = {},
125
+ ): DiffSource {
126
+ const range = `${base}...${head}`;
127
+ const patch = runGit(["diff", range]);
128
+ return {
129
+ byFile: parseDiffIndex(
130
+ patch,
131
+ options.maxPerFileChars ?? DEFAULT_MAX_PER_FILE_CHARS,
132
+ ),
133
+ churn: collectChurn(base, head),
134
+ };
135
+ }
136
+
137
+ /** Converte o Map de churn em Record (para o `classification.json`). */
138
+ export function churnToRecord(churn: Map<string, FileChurn>): ChurnMap {
139
+ return Object.fromEntries(churn);
140
+ }
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
  import type { HoundsmanConfig } from "./config";
10
- import type { ClassifyRule, WatchdogDefinition } from "./types";
10
+ import { churnToRecord, collectChurn, type DiffSource } from "./diff-source";
11
+ import type { ChurnMap, ClassifyRule, WatchdogDefinition } from "./types";
11
12
 
12
13
  type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
13
14
 
@@ -22,6 +23,8 @@ export type ClassificationResult = {
22
23
  watchdogs: Record<string, string[]>;
23
24
  /** path → metadados (status, oldPath) */
24
25
  files: Record<string, { status: FileStatus; oldPath?: string }>;
26
+ /** path → linhas adicionadas/removidas (churn do diff) */
27
+ churn: ChurnMap;
25
28
  /** Arquivos não classificados (revisar manualmente) */
26
29
  unclassified: string[];
27
30
  /** Arquivos excluídos da revisão (gerados, locks, builds) */
@@ -167,6 +170,7 @@ export function parseEntries(raw: string): FileEntry[] {
167
170
  export function classifyEntries<TId extends string>(
168
171
  entries: FileEntry[],
169
172
  config: HoundsmanConfig<TId>,
173
+ churn: ChurnMap = {},
170
174
  ): ClassificationResult {
171
175
  const watchdogFiles: Record<string, string[]> = {};
172
176
  const files: ClassificationResult["files"] = {};
@@ -223,6 +227,7 @@ export function classifyEntries<TId extends string>(
223
227
  return {
224
228
  watchdogs,
225
229
  files,
230
+ churn,
226
231
  unclassified,
227
232
  excluded,
228
233
  deleted,
@@ -233,15 +238,58 @@ export function classifyEntries<TId extends string>(
233
238
 
234
239
  /**
235
240
  * Executa `git diff` entre base e head e classifica os arquivos alterados.
241
+ *
242
+ * `diff` (opcional) reaproveita um `DiffSource` já coletado — evita rodar o git
243
+ * de novo quando o chamador (ex.: `prepare`) precisa também do patch para o Jev.
236
244
  */
237
245
  export function classifyRange<TId extends string>(
238
246
  base: string,
239
247
  head: string,
240
248
  config: HoundsmanConfig<TId>,
249
+ diff?: DiffSource,
241
250
  ): ClassificationResult {
242
251
  const entries = parseEntries(runGitDiff(base, head));
243
- const result = classifyEntries(entries, config);
252
+ const churn = diff?.churn ?? collectChurn(base, head);
253
+ const result = classifyEntries(entries, config, churnToRecord(churn));
244
254
  result.base = base;
245
255
  result.head = head;
246
256
  return result;
247
257
  }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Triage (PRs grandes)
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** Churn total (adições + remoções) de um arquivo; 0 quando ausente. */
264
+ export function churnOf(filePath: string, churn: ChurnMap | undefined): number {
265
+ const entry = churn?.[filePath];
266
+ return entry ? entry.additions + entry.deletions : 0;
267
+ }
268
+
269
+ /** Ordena os arquivos por risco (churn desc; empate pelo caminho). */
270
+ export function sortByRisk(files: readonly string[], churn?: ChurnMap): string[] {
271
+ return [...files].sort((a, b) => {
272
+ const delta = churnOf(b, churn) - churnOf(a, churn);
273
+ return delta !== 0 ? delta : a.localeCompare(b);
274
+ });
275
+ }
276
+
277
+ /** Resultado da aplicação do orçamento: o que revisar e o que ficou de fora. */
278
+ export type BudgetSelection = { selected: string[]; overflow: string[] };
279
+
280
+ /**
281
+ * Aplica o orçamento por brief: mantém os `maxFiles` de maior risco e devolve o
282
+ * restante em `overflow` (nunca descarta silenciosamente). `maxFiles` não-positivo
283
+ * ou não-finito desativa o orçamento.
284
+ */
285
+ export function selectByBudget(
286
+ files: readonly string[],
287
+ churn: ChurnMap | undefined,
288
+ maxFiles: number,
289
+ ): BudgetSelection {
290
+ const ordered = sortByRisk(files, churn);
291
+ if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
292
+ return { selected: ordered, overflow: [] };
293
+ }
294
+ return { selected: ordered.slice(0, maxFiles), overflow: ordered.slice(maxFiles) };
295
+ }
@@ -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
+ }