@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.
- package/README.md +39 -78
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +89 -9
- package/src/core/assets.ts +8 -0
- package/src/core/brief.ts +25 -2
- package/src/core/commands/findings.ts +390 -0
- package/src/core/commands/scaffold.ts +20 -0
- 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 +538 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/orchestrator.ts +169 -17
- package/src/core/paths.ts +3 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +113 -9
- package/src/core/types.ts +10 -0
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +66 -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 +43 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +63 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +48 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +365 -0
- package/src/skill/README.md +14 -6
- package/src/skill/SETUP.md +5 -3
- package/src/skill/houndsman-update.md +1 -1
- package/src/skill/houndsman.md +87 -10
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +226 -0
- package/src/tests/findings-state.test.ts +274 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/server.test.ts +212 -0
|
@@ -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
|
+
}
|