@payconductor/houndsman 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +195 -3
- package/assets/houndsman.png +0 -0
- package/package.json +63 -5
- package/src/cli/index.ts +210 -0
- package/src/core/assets.ts +20 -0
- package/src/core/brief.ts +162 -0
- package/src/core/commands/context.ts +39 -0
- package/src/core/commands/review.ts +50 -0
- package/src/core/commands/scaffold.ts +373 -0
- package/src/core/commands/setup.ts +75 -0
- package/src/core/commands/test.ts +20 -0
- package/src/core/commands/update.ts +30 -0
- package/src/core/commands/watchdog.ts +105 -0
- package/src/core/config-loader.ts +87 -0
- package/src/core/config.ts +59 -0
- package/src/core/doc-discoverer.ts +33 -0
- package/src/core/file-classifier.ts +247 -0
- package/src/core/jev/index.ts +12 -0
- package/src/core/jev/jev-candidates.ts +105 -0
- package/src/core/jev/jev-client.ts +99 -0
- package/src/core/jev/jev-detector.ts +203 -0
- package/src/core/jev/jev-questions.ts +92 -0
- package/src/core/jev/jev-types.ts +91 -0
- package/src/core/orchestrator.ts +444 -0
- package/src/core/paths.ts +30 -0
- package/src/core/pr-context.ts +271 -0
- package/src/core/registry-check.ts +145 -0
- package/src/core/report-builder.ts +357 -0
- package/src/core/types.ts +137 -0
- package/src/core/watchdog-registry.ts +43 -0
- package/src/index.ts +56 -0
- package/src/skill/AUDIT-PROTOCOL.md +96 -0
- package/src/skill/OUTPUT-FORMAT.md +70 -0
- package/src/skill/README.md +116 -0
- package/src/skill/SETUP.md +99 -0
- package/src/skill/example-review.md +58 -0
- package/src/skill/houndsman-update.md +251 -0
- package/src/skill/houndsman.md +239 -0
- package/src/tests/fixtures/fake-project.ts +64 -0
- package/src/tests/review-scripts.test.ts +851 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detector de candidatos (Passo 1.5) — roda as perguntas Jev por arquivo.
|
|
3
|
+
*
|
|
4
|
+
* Para cada arquivo revisável: monta o `state` (topo do arquivo + hunk do diff),
|
|
5
|
+
* reúne as perguntas dos watchdogs do arquivo e consulta o modelo. Respostas
|
|
6
|
+
* `noul` acima do limiar viram candidatos.
|
|
7
|
+
*
|
|
8
|
+
* Falha-aberto: erro por arquivo é registrado em `skipped` e não interrompe.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import type { ClassificationResult } from "../file-classifier";
|
|
13
|
+
import type { WatchdogMeta } from "../types";
|
|
14
|
+
import {
|
|
15
|
+
type JevCandidate,
|
|
16
|
+
JevCandidatesStore,
|
|
17
|
+
type JevSkippedFile,
|
|
18
|
+
} from "./jev-candidates";
|
|
19
|
+
import type { JevClient } from "./jev-client";
|
|
20
|
+
import { type DerivedQuestion, JevQuestionFactory } from "./jev-questions";
|
|
21
|
+
import type { JevQuestion } from "./jev-types";
|
|
22
|
+
|
|
23
|
+
export type DetectOptions = {
|
|
24
|
+
/** Limiar de confiança (`noul`) para virar candidato. */
|
|
25
|
+
threshold?: number;
|
|
26
|
+
/** Nº de arquivos avaliados em paralelo. */
|
|
27
|
+
concurrency?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type DetectResult = {
|
|
31
|
+
candidates: JevCandidate[];
|
|
32
|
+
skipped: JevSkippedFile[];
|
|
33
|
+
analyzedFiles: number;
|
|
34
|
+
model?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Orquestra a pré-análise Jev sobre um diff classificado. */
|
|
38
|
+
export class JevDetector {
|
|
39
|
+
/** Linhas iniciais do arquivo (imports/declarações) incluídas no `state`. */
|
|
40
|
+
private static readonly HEADER_LINES = 60;
|
|
41
|
+
private static readonly MAX_STATE_CHARS = 8_000;
|
|
42
|
+
private static readonly MAX_DIFF_CHARS = 4_000;
|
|
43
|
+
private static readonly DEFAULT_CONCURRENCY = 5;
|
|
44
|
+
|
|
45
|
+
private readonly client: JevClient;
|
|
46
|
+
private readonly questions: JevQuestionFactory;
|
|
47
|
+
|
|
48
|
+
/** Construtor que recebe o cliente e o registry de watchdogs (injeção). */
|
|
49
|
+
constructor(client: JevClient, registry: Record<string, WatchdogMeta>) {
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.questions = new JevQuestionFactory(registry);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Roda a pré-análise e devolve os candidatos (ordenados por confiança desc). */
|
|
55
|
+
async detect(
|
|
56
|
+
classification: ClassificationResult,
|
|
57
|
+
base: string,
|
|
58
|
+
head: string,
|
|
59
|
+
options: DetectOptions = {},
|
|
60
|
+
): Promise<DetectResult> {
|
|
61
|
+
const threshold = options.threshold ?? JevCandidatesStore.THRESHOLD;
|
|
62
|
+
const concurrency = options.concurrency ?? JevDetector.DEFAULT_CONCURRENCY;
|
|
63
|
+
|
|
64
|
+
const jobs = this.buildJobs(classification);
|
|
65
|
+
const questionByKey = new Map<string, DerivedQuestion>();
|
|
66
|
+
for (const job of jobs) {
|
|
67
|
+
for (const dq of job.questions) questionByKey.set(dq.key, dq);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const candidates: JevCandidate[] = [];
|
|
71
|
+
const skipped: JevSkippedFile[] = [];
|
|
72
|
+
let model: string | undefined;
|
|
73
|
+
|
|
74
|
+
await JevDetector.mapWithConcurrency(jobs, concurrency, async (job) => {
|
|
75
|
+
const questions: Record<string, JevQuestion> = {};
|
|
76
|
+
for (const dq of job.questions) questions[dq.key] = dq.question;
|
|
77
|
+
|
|
78
|
+
const status = classification.files[job.file]?.status ?? "modified";
|
|
79
|
+
const state = this.buildState(job.file, status, base, head);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await this.client.evaluate({ state, questions });
|
|
83
|
+
model ??= response.model;
|
|
84
|
+
for (const [key, answer] of Object.entries(response.answers)) {
|
|
85
|
+
if (answer.type !== "noul" || answer.noul < threshold) continue;
|
|
86
|
+
const dq = questionByKey.get(key);
|
|
87
|
+
if (!dq) continue;
|
|
88
|
+
candidates.push({
|
|
89
|
+
watchdogId: dq.watchdogId,
|
|
90
|
+
rule: dq.rule,
|
|
91
|
+
severity: dq.severity,
|
|
92
|
+
file: job.file,
|
|
93
|
+
confidence: answer.noul,
|
|
94
|
+
questionKey: key,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
skipped.push({
|
|
99
|
+
file: job.file,
|
|
100
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
candidates.sort((a, b) => b.confidence - a.confidence);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
candidates,
|
|
109
|
+
skipped,
|
|
110
|
+
analyzedFiles: jobs.length,
|
|
111
|
+
model,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Arquivo → perguntas dos watchdogs que o cobrem (só arquivos com perguntas). */
|
|
116
|
+
private buildJobs(
|
|
117
|
+
classification: ClassificationResult,
|
|
118
|
+
): { file: string; questions: DerivedQuestion[] }[] {
|
|
119
|
+
const filesToWatchdogs = JevDetector.mapFilesToWatchdogs(classification);
|
|
120
|
+
return [...filesToWatchdogs.entries()]
|
|
121
|
+
.map(([file, watchdogs]) => ({
|
|
122
|
+
file,
|
|
123
|
+
questions: this.questions.build([...watchdogs]),
|
|
124
|
+
}))
|
|
125
|
+
.filter((job) => job.questions.length > 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Monta o `state` de um arquivo: cabeçalho + hunk do diff, com cap de tamanho. */
|
|
129
|
+
private buildState(
|
|
130
|
+
filePath: string,
|
|
131
|
+
status: string,
|
|
132
|
+
base: string,
|
|
133
|
+
head: string,
|
|
134
|
+
): string {
|
|
135
|
+
const header = this.readHeader(filePath);
|
|
136
|
+
const hunk = this.readHunk(base, head, filePath);
|
|
137
|
+
const parts = [`Arquivo: ${filePath} (${status})`];
|
|
138
|
+
if (header) {
|
|
139
|
+
parts.push("--- topo do arquivo (imports/declarações) ---", header);
|
|
140
|
+
}
|
|
141
|
+
if (hunk) parts.push("--- diff ---", hunk);
|
|
142
|
+
return parts.join("\n").slice(0, JevDetector.MAX_STATE_CHARS);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private readHeader(filePath: string): string {
|
|
146
|
+
try {
|
|
147
|
+
return readFileSync(filePath, "utf8")
|
|
148
|
+
.split("\n")
|
|
149
|
+
.slice(0, JevDetector.HEADER_LINES)
|
|
150
|
+
.join("\n");
|
|
151
|
+
} catch {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private readHunk(base: string, head: string, filePath: string): string {
|
|
157
|
+
const result = Bun.spawnSync(
|
|
158
|
+
["git", "diff", `${base}...${head}`, "--", filePath],
|
|
159
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
160
|
+
);
|
|
161
|
+
if (result.exitCode !== 0) return "";
|
|
162
|
+
return new TextDecoder()
|
|
163
|
+
.decode(result.stdout)
|
|
164
|
+
.slice(0, JevDetector.MAX_DIFF_CHARS);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Arquivo → watchdogs que o cobrem. */
|
|
168
|
+
private static mapFilesToWatchdogs(
|
|
169
|
+
classification: ClassificationResult,
|
|
170
|
+
): Map<string, Set<string>> {
|
|
171
|
+
const map = new Map<string, Set<string>>();
|
|
172
|
+
for (const [id, files] of Object.entries(classification.watchdogs)) {
|
|
173
|
+
for (const file of files ?? []) {
|
|
174
|
+
let set = map.get(file);
|
|
175
|
+
if (!set) {
|
|
176
|
+
set = new Set<string>();
|
|
177
|
+
map.set(file, set);
|
|
178
|
+
}
|
|
179
|
+
set.add(id);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return map;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Executa `fn` sobre `items` com concorrência limitada (ordem irrelevante). */
|
|
186
|
+
private static async mapWithConcurrency<T>(
|
|
187
|
+
items: readonly T[],
|
|
188
|
+
limit: number,
|
|
189
|
+
fn: (item: T) => Promise<void>,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
let cursor = 0;
|
|
192
|
+
const workers = Array.from(
|
|
193
|
+
{ length: Math.min(limit, items.length) },
|
|
194
|
+
async () => {
|
|
195
|
+
while (cursor < items.length) {
|
|
196
|
+
const current = items[cursor++];
|
|
197
|
+
await fn(current);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
await Promise.all(workers);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deriva perguntas Jev (tipadas) a partir das regras do `WATCHDOG_DEFINITIONS`.
|
|
3
|
+
*
|
|
4
|
+
* Fonte única de verdade: o registry. Uma regra só gera pergunta se tiver a
|
|
5
|
+
* spec Jev (campo opcional `jev` de `Rule`). Assim não há lista paralela para
|
|
6
|
+
* sincronizar — alterar a regra no registry altera a pergunta.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Rule, Severity, WatchdogMeta } from "../types";
|
|
10
|
+
import type { JevQuestion, JevRuleSpec } from "./jev-types";
|
|
11
|
+
|
|
12
|
+
export type DerivedQuestion = {
|
|
13
|
+
/** Chave estável da pergunta (`<watchdogId>::<bucket>-<índice>`). */
|
|
14
|
+
key: string;
|
|
15
|
+
watchdogId: string;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
/** Descrição original da regra (usada como `rule` no achado confirmado). */
|
|
18
|
+
rule: string;
|
|
19
|
+
question: JevQuestion;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Converte regras do registry em perguntas tipadas para a API.
|
|
24
|
+
*
|
|
25
|
+
* Encapsula toda a lógica de conversão/derivação. O registry (watchdogs) é
|
|
26
|
+
* injetado via `HoundsmanConfig`, permitindo testes sem tocar no registry real.
|
|
27
|
+
*/
|
|
28
|
+
export class JevQuestionFactory {
|
|
29
|
+
private readonly registry: Record<string, WatchdogMeta>;
|
|
30
|
+
|
|
31
|
+
constructor(registry: Record<string, WatchdogMeta>) {
|
|
32
|
+
this.registry = registry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Converte uma `JevRuleSpec` em `JevQuestion`; null se a spec for inválida. */
|
|
36
|
+
fromSpec(description: string, spec: JevRuleSpec): JevQuestion | null {
|
|
37
|
+
const instructions = (spec.instructions ?? this.noul(description)).trim();
|
|
38
|
+
if (!instructions) return null;
|
|
39
|
+
|
|
40
|
+
if (spec.kind === "noul") return { type: "noul", instructions };
|
|
41
|
+
|
|
42
|
+
if (spec.kind === "choice") {
|
|
43
|
+
const criteria = spec.criteria;
|
|
44
|
+
if (!criteria || Array.isArray(criteria)) return null;
|
|
45
|
+
if (Object.keys(criteria).length === 0) return null;
|
|
46
|
+
return { type: "choice", instructions, criteria };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// score
|
|
50
|
+
if (!Array.isArray(spec.criteria) || spec.criteria.length === 0) return null;
|
|
51
|
+
return { type: "score", instructions, criteria: spec.criteria };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Perguntas derivadas das regras de um watchdog (`rules`). */
|
|
55
|
+
derive(watchdogId: string): DerivedQuestion[] {
|
|
56
|
+
const meta = this.registry[watchdogId];
|
|
57
|
+
if (!meta) return [];
|
|
58
|
+
|
|
59
|
+
const out: DerivedQuestion[] = [];
|
|
60
|
+
meta.rules.forEach((rule, index) => {
|
|
61
|
+
const spec = this.specOf(rule);
|
|
62
|
+
if (!spec) return;
|
|
63
|
+
const question = this.fromSpec(rule.text, spec);
|
|
64
|
+
if (!question) return;
|
|
65
|
+
out.push({
|
|
66
|
+
key: `${watchdogId}::rule-${index}`,
|
|
67
|
+
watchdogId,
|
|
68
|
+
severity: rule.severity,
|
|
69
|
+
rule: rule.text,
|
|
70
|
+
question,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Perguntas derivadas para um conjunto de watchdogs. */
|
|
77
|
+
build(watchdogIds: readonly string[]): DerivedQuestion[] {
|
|
78
|
+
return watchdogIds.flatMap((id) => this.derive(id));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Acesso tipado à spec Jev da regra (campo opcional `jev`).
|
|
83
|
+
*/
|
|
84
|
+
specOf(rule: Rule): JevRuleSpec | undefined {
|
|
85
|
+
return rule.jev;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Instrução padrão quando a spec não traz `instructions`. */
|
|
89
|
+
private noul(description: string): string {
|
|
90
|
+
return `No estado fornecido, ocorre a seguinte violação? "${description}"`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipos da API TypeSafe "System One" (modelo Jev).
|
|
3
|
+
*
|
|
4
|
+
* Contrato: `POST {baseURL}/v1/systemone`
|
|
5
|
+
* Body: { state, model?, questions: Record<key, JevQuestion> }
|
|
6
|
+
* Resp: { model, answers: Record<key, JevAnswer>, usage? }
|
|
7
|
+
*
|
|
8
|
+
* Cada pergunta é avaliada em paralelo e em isolamento contra o mesmo `state`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Tipo de pergunta aceito pela API. */
|
|
12
|
+
export type JevQuestionType = "noul" | "choice" | "score";
|
|
13
|
+
|
|
14
|
+
/** "A afirmação é verdadeira?" → retorna probabilidade 0–1. */
|
|
15
|
+
export type JevNoulQuestion = {
|
|
16
|
+
type: "noul";
|
|
17
|
+
instructions: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Escolhe uma opção de uma lista. */
|
|
21
|
+
export type JevChoiceQuestion = {
|
|
22
|
+
type: "choice";
|
|
23
|
+
instructions: string;
|
|
24
|
+
criteria: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Pontua o estado em uma rubrica (índice da lista). */
|
|
28
|
+
export type JevScoreQuestion = {
|
|
29
|
+
type: "score";
|
|
30
|
+
instructions: string;
|
|
31
|
+
criteria: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type JevQuestion = JevNoulQuestion | JevChoiceQuestion | JevScoreQuestion;
|
|
35
|
+
|
|
36
|
+
export type JevEvaluateRequest = {
|
|
37
|
+
state: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
questions: Record<string, JevQuestion>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type JevNoulAnswer = {
|
|
43
|
+
type: "noul";
|
|
44
|
+
noul: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type JevChoiceAnswer = {
|
|
48
|
+
type: "choice";
|
|
49
|
+
choice: string;
|
|
50
|
+
confidence: number;
|
|
51
|
+
probabilities: Record<string, number>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type JevScoreAnswer = {
|
|
55
|
+
type: "score";
|
|
56
|
+
score: number;
|
|
57
|
+
confidence: number;
|
|
58
|
+
legend?: Record<string, string>;
|
|
59
|
+
probabilities?: Record<string, number>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type JevAnswer = JevNoulAnswer | JevChoiceAnswer | JevScoreAnswer;
|
|
63
|
+
|
|
64
|
+
export type JevUsage = {
|
|
65
|
+
input_tokens?: number;
|
|
66
|
+
output_tokens?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type JevEvaluateResponse = {
|
|
70
|
+
model: string;
|
|
71
|
+
answers: Record<string, JevAnswer>;
|
|
72
|
+
usage?: JevUsage;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Spec de conversão de uma regra do registry em pergunta Jev.
|
|
77
|
+
*
|
|
78
|
+
* Sempre um objeto — `kind` é obrigatório para deixar explícito o que a spec
|
|
79
|
+
* faz (a presença de `jev` não é um liga/desliga do Jev).
|
|
80
|
+
*
|
|
81
|
+
* - `kind: "noul"` → "a violação descrita ocorre?" (probabilidade 0–1).
|
|
82
|
+
* - `kind: "choice"` → escolha entre opções (`criteria` obrigatório, objeto).
|
|
83
|
+
* - `kind: "score"` → nota numa rubrica (`criteria` obrigatório, lista).
|
|
84
|
+
*
|
|
85
|
+
* `instructions` omitida → derivada da própria descrição da regra.
|
|
86
|
+
*/
|
|
87
|
+
export type JevRuleSpec = {
|
|
88
|
+
kind: JevQuestionType;
|
|
89
|
+
instructions?: string;
|
|
90
|
+
criteria?: string[] | Record<string, string>;
|
|
91
|
+
};
|