@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.
- package/README.md +68 -89
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +101 -10
- package/src/core/assets.ts +11 -0
- package/src/core/brief.ts +27 -5
- package/src/core/commands/findings.ts +618 -0
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +33 -44
- package/src/core/config.ts +42 -0
- package/src/core/diff-source.ts +140 -0
- package/src/core/file-classifier.ts +50 -2
- package/src/core/finding-anchor.ts +147 -0
- package/src/core/findings-state.ts +556 -0
- package/src/core/fix-batch.ts +236 -0
- package/src/core/git-revision.ts +105 -0
- package/src/core/jev/index.ts +1 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +362 -23
- package/src/core/paths.ts +9 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +118 -9
- package/src/core/run-status.ts +123 -0
- package/src/core/types.ts +10 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +67 -0
- package/src/server/client/App.tsx +158 -0
- package/src/server/client/ArtifactsPanel.tsx +48 -0
- package/src/server/client/Filters.tsx +83 -0
- package/src/server/client/FindingCard.tsx +122 -0
- package/src/server/client/FindingsList.tsx +54 -0
- package/src/server/client/RunList.tsx +52 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +94 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +52 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +376 -0
- package/src/skill/AUDIT-PROTOCOL.md +2 -2
- package/src/skill/README.md +43 -11
- package/src/skill/SETUP.md +16 -4
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +83 -33
- package/src/skill/{houndsman-update.md → train.md} +12 -12
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +476 -0
- package/src/tests/findings-state.test.ts +325 -0
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +242 -0
- 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
|
|
329
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
365
|
-
|
|
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
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|