@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descoberta e carregamento da configuração do projeto — biblioteca.
|
|
3
|
+
*
|
|
4
|
+
* O motor é agnóstico: a config do projeto (`HoundsmanConfig`) é resolvida em
|
|
5
|
+
* runtime a partir do cwd do consumidor — nunca importada estaticamente.
|
|
6
|
+
*
|
|
7
|
+
* Ordem de resolução:
|
|
8
|
+
* 1. `--config <path>` (explícito)
|
|
9
|
+
* 2. `houndsman.config.{ts,mts,js,mjs}`
|
|
10
|
+
* 3. `.houndsman/config.{ts,js}`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import type { HoundsmanConfig } from "./config";
|
|
17
|
+
|
|
18
|
+
/** Nomes de arquivo procurados no cwd, em ordem de precedência. */
|
|
19
|
+
export const CONFIG_FILE_CANDIDATES: readonly string[] = [
|
|
20
|
+
"houndsman.config.ts",
|
|
21
|
+
"houndsman.config.mts",
|
|
22
|
+
"houndsman.config.js",
|
|
23
|
+
"houndsman.config.mjs",
|
|
24
|
+
".houndsman/config.ts",
|
|
25
|
+
".houndsman/config.js",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export type LoadedConfig<TId extends string = string> = {
|
|
29
|
+
config: HoundsmanConfig<TId>;
|
|
30
|
+
/** Caminho absoluto do arquivo de config carregado. */
|
|
31
|
+
path: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Primeiro arquivo de config existente no diretório (ou `null`). */
|
|
35
|
+
export function resolveConfigPath(cwd: string = process.cwd()): string | null {
|
|
36
|
+
for (const name of CONFIG_FILE_CANDIDATES) {
|
|
37
|
+
const candidate = join(cwd, name);
|
|
38
|
+
if (existsSync(candidate)) return candidate;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MISSING_CONFIG_HINT =
|
|
44
|
+
"Nenhuma configuração do houndsman encontrada.\n" +
|
|
45
|
+
`Esperado: ${CONFIG_FILE_CANDIDATES.join(" | ")} na raiz do projeto.\n` +
|
|
46
|
+
"Rode `houndsman setup` para criar uma configuração inicial.";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Carrega o `HoundsmanConfig` do projeto. Lança se ausente ou inválido.
|
|
50
|
+
* `configPath` sobrepõe a descoberta automática.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadConfig<TId extends string = string>(
|
|
53
|
+
options: { cwd?: string; configPath?: string } = {},
|
|
54
|
+
): Promise<LoadedConfig<TId>> {
|
|
55
|
+
const cwd = options.cwd ?? process.cwd();
|
|
56
|
+
const path = options.configPath
|
|
57
|
+
? resolve(cwd, options.configPath)
|
|
58
|
+
: resolveConfigPath(cwd);
|
|
59
|
+
|
|
60
|
+
if (!path || !existsSync(path)) {
|
|
61
|
+
throw new Error(MISSING_CONFIG_HINT);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const module = (await import(pathToFileURL(path).href)) as {
|
|
65
|
+
default?: HoundsmanConfig<TId>;
|
|
66
|
+
houndsmanConfig?: HoundsmanConfig<TId>;
|
|
67
|
+
};
|
|
68
|
+
const config = module.default ?? module.houndsmanConfig;
|
|
69
|
+
|
|
70
|
+
if (!config) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Configuração inválida em ${path}: exporte \`export default houndsmanConfig\`.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (!config.watchdogs || typeof config.watchdogs !== "object") {
|
|
76
|
+
throw new Error(`Configuração inválida em ${path}: campo 'watchdogs' ausente.`);
|
|
77
|
+
}
|
|
78
|
+
// Deriva `watchdogIds` de quem não passou por `defineHoundsmanConfig` (ex.: objeto
|
|
79
|
+
// literal) — mantém o motor robusto a configs escritas à mão.
|
|
80
|
+
if (!Array.isArray(config.watchdogIds)) {
|
|
81
|
+
(config as unknown as { watchdogIds: string[] }).watchdogIds = Object.keys(
|
|
82
|
+
config.watchdogs,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { config, path };
|
|
87
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrato de configuração do motor de revisão (houndsman) — biblioteca.
|
|
3
|
+
*
|
|
4
|
+
* O motor (`core/` + `cli/`) é agnóstico ao projeto: consome um `HoundsmanConfig`
|
|
5
|
+
* por injeção explícita. O projeto fornece a config em `houndsman.config.ts`
|
|
6
|
+
* (camada `houndsman/`) — único ponto de troca ao portar para outro repositório.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ARTIFACT_DIR } from "./paths";
|
|
10
|
+
import type { WatchdogDefinition } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Documentos sempre incluídos nos briefs (default). */
|
|
13
|
+
export const DEFAULT_ALWAYS_INCLUDE_DOCS: readonly string[] = ["AGENTS.md"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuração completa do motor de revisão para um projeto.
|
|
17
|
+
*
|
|
18
|
+
* `TId` é o union de ids de watchdog do projeto (ex.: `WatchdogId`).
|
|
19
|
+
*/
|
|
20
|
+
export type HoundsmanConfig<TId extends string = string> = {
|
|
21
|
+
/** Nome legível do projeto (usado em prompts e docs). */
|
|
22
|
+
projectName: string;
|
|
23
|
+
/** Diretório dos artefatos (default: `ARTIFACT_DIR` da camada). */
|
|
24
|
+
artifactDir?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Ids dos watchdogs — **derivados** das chaves de `watchdogs` por
|
|
27
|
+
* `defineHoundsmanConfig` (não informar manualmente).
|
|
28
|
+
*/
|
|
29
|
+
watchdogIds: readonly TId[];
|
|
30
|
+
/** Definições dos watchdogs do projeto (fonte dos ids). */
|
|
31
|
+
watchdogs: Record<TId, WatchdogDefinition>;
|
|
32
|
+
/** Arquivos que nunca entram na revisão. */
|
|
33
|
+
exclusions: readonly RegExp[];
|
|
34
|
+
/** Mapeamentos path → documentação relevante. */
|
|
35
|
+
docMappings: readonly (readonly [RegExp, readonly string[]])[];
|
|
36
|
+
/** Docs sempre incluídos nos briefs (default: `DEFAULT_ALWAYS_INCLUDE_DOCS`). */
|
|
37
|
+
alwaysIncludeDocs?: readonly string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Entrada de `defineHoundsmanConfig` — `watchdogIds` é derivado, não informado. */
|
|
41
|
+
export type HoundsmanConfigInput<TId extends string = string> = Omit<
|
|
42
|
+
HoundsmanConfig<TId>,
|
|
43
|
+
"watchdogIds"
|
|
44
|
+
>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Helper de autoria: **deriva `watchdogIds`** das chaves de `watchdogs`
|
|
48
|
+
* (fonte única) e preserva a inferência de `TId`.
|
|
49
|
+
*/
|
|
50
|
+
export function defineHoundsmanConfig<TId extends string>(
|
|
51
|
+
input: HoundsmanConfigInput<TId>,
|
|
52
|
+
): HoundsmanConfig<TId> {
|
|
53
|
+
return { ...input, watchdogIds: Object.keys(input.watchdogs) as TId[] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Diretório dos artefatos — o valor do projeto ou o default da camada. */
|
|
57
|
+
export function resolveArtifactDir(config: HoundsmanConfig): string {
|
|
58
|
+
return config.artifactDir ?? ARTIFACT_DIR;
|
|
59
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descobre documentação relevante para um conjunto de arquivos alterados
|
|
3
|
+
* (biblioteca). Entrypoint: `cli/review.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Os mapeamentos (`docMappings`) e os docs sempre incluídos vivem no projeto
|
|
6
|
+
* (`project/doc-mappings.ts`) e chegam via `HoundsmanConfig`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_ALWAYS_INCLUDE_DOCS, type HoundsmanConfig } from "./config";
|
|
10
|
+
|
|
11
|
+
/** Descobre docs relevantes com base nos paths + docs sempre incluídos. */
|
|
12
|
+
export function discoverDocs(filePaths: string[], config: HoundsmanConfig): string[] {
|
|
13
|
+
const docs = new Set<string>();
|
|
14
|
+
|
|
15
|
+
for (const doc of config.alwaysIncludeDocs ?? DEFAULT_ALWAYS_INCLUDE_DOCS) {
|
|
16
|
+
docs.add(doc);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const filePath of filePaths) {
|
|
20
|
+
const normalized = filePath.trim().toLowerCase();
|
|
21
|
+
if (!normalized) continue;
|
|
22
|
+
|
|
23
|
+
for (const [pattern, docPaths] of config.docMappings) {
|
|
24
|
+
if (pattern.test(normalized)) {
|
|
25
|
+
for (const docPath of docPaths) {
|
|
26
|
+
docs.add(docPath);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [...docs].sort();
|
|
33
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classificação de arquivos alterados por domínio de review (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Cada watchdog declara os arquivos que cobre (`classify`); exclusões chegam via
|
|
5
|
+
* `HoundsmanConfig`. Use `classifyRange(base, head, config)`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import type { HoundsmanConfig } from "./config";
|
|
10
|
+
import type { ClassifyRule, WatchdogDefinition } from "./types";
|
|
11
|
+
|
|
12
|
+
type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
|
|
13
|
+
|
|
14
|
+
type FileEntry = {
|
|
15
|
+
path: string;
|
|
16
|
+
status: FileStatus;
|
|
17
|
+
oldPath?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ClassificationResult = {
|
|
21
|
+
/** watchdogId → file paths (apenas arquivos revisáveis) */
|
|
22
|
+
watchdogs: Record<string, string[]>;
|
|
23
|
+
/** path → metadados (status, oldPath) */
|
|
24
|
+
files: Record<string, { status: FileStatus; oldPath?: string }>;
|
|
25
|
+
/** Arquivos não classificados (revisar manualmente) */
|
|
26
|
+
unclassified: string[];
|
|
27
|
+
/** Arquivos excluídos da revisão (gerados, locks, builds) */
|
|
28
|
+
excluded: string[];
|
|
29
|
+
/** Arquivos deletados — candidatos a breaking change, sem conteúdo para ler */
|
|
30
|
+
deleted: string[];
|
|
31
|
+
/** Estatísticas por watchdog */
|
|
32
|
+
stats: Record<string, number>;
|
|
33
|
+
excludedCount: number;
|
|
34
|
+
base?: string;
|
|
35
|
+
head?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Matchers genéricos (reutilizados pelo projeto)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** Tipo de classificação assumido quando `type` é omitido. */
|
|
43
|
+
const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
|
|
44
|
+
|
|
45
|
+
/** `true` se há entradas de conteúdo (as únicas que exigem ler o arquivo). */
|
|
46
|
+
function hasContentRules<TId extends string>(
|
|
47
|
+
watchdogs: Record<TId, WatchdogDefinition>,
|
|
48
|
+
watchdogIds: readonly TId[],
|
|
49
|
+
): boolean {
|
|
50
|
+
return watchdogIds.some((id) =>
|
|
51
|
+
watchdogs[id]?.classify?.some((rule) => rule.type === "content"),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lê o arquivo em UTF-8; `undefined` se indisponível (binário, ausente). */
|
|
56
|
+
function readFileContent(filePath: string): string | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(filePath, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `true` se o arquivo casa a entrada de classificação. */
|
|
65
|
+
function matchesClassifyRule(
|
|
66
|
+
filePath: string,
|
|
67
|
+
rule: ClassifyRule,
|
|
68
|
+
content: string | undefined,
|
|
69
|
+
): boolean {
|
|
70
|
+
if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
|
|
71
|
+
return content !== undefined && new RegExp(rule.value).test(content);
|
|
72
|
+
}
|
|
73
|
+
return new RegExp(rule.value).test(filePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Watchdogs que declaram o arquivo (união dos `classify`). */
|
|
77
|
+
export function matchWatchdogs<TId extends string>(
|
|
78
|
+
filePath: string,
|
|
79
|
+
watchdogs: Record<TId, WatchdogDefinition>,
|
|
80
|
+
watchdogIds: readonly TId[],
|
|
81
|
+
content?: string,
|
|
82
|
+
): TId[] {
|
|
83
|
+
const ids: TId[] = [];
|
|
84
|
+
for (const id of watchdogIds) {
|
|
85
|
+
if (
|
|
86
|
+
watchdogs[id]?.classify?.some((rule) =>
|
|
87
|
+
matchesClassifyRule(filePath, rule, content),
|
|
88
|
+
)
|
|
89
|
+
) {
|
|
90
|
+
ids.push(id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return ids;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `true` se o arquivo casa algum padrão de exclusão. */
|
|
97
|
+
export function isExcludedPath(filePath: string, patterns: readonly RegExp[]): boolean {
|
|
98
|
+
return patterns.some((pattern) => pattern.test(filePath));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Execução do git diff
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function runGitDiff(base: string, head: string): string {
|
|
106
|
+
const result = Bun.spawnSync(["git", "diff", `${base}...${head}`, "--name-status"], {
|
|
107
|
+
stdout: "pipe",
|
|
108
|
+
stderr: "pipe",
|
|
109
|
+
});
|
|
110
|
+
if (result.exitCode !== 0) {
|
|
111
|
+
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
112
|
+
throw new Error(`git diff falhou (base='${base}', head='${head}'): ${stderr}`);
|
|
113
|
+
}
|
|
114
|
+
return new TextDecoder().decode(result.stdout);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Parsing de `git diff --name-status`
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function mapStatus(code: string): FileStatus {
|
|
122
|
+
const letter = code[0]?.toUpperCase();
|
|
123
|
+
switch (letter) {
|
|
124
|
+
case "A":
|
|
125
|
+
return "added";
|
|
126
|
+
case "M":
|
|
127
|
+
return "modified";
|
|
128
|
+
case "D":
|
|
129
|
+
return "deleted";
|
|
130
|
+
case "R":
|
|
131
|
+
return "renamed";
|
|
132
|
+
case "C":
|
|
133
|
+
return "copied";
|
|
134
|
+
default:
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseEntries(raw: string): FileEntry[] {
|
|
140
|
+
const entries: FileEntry[] = [];
|
|
141
|
+
for (const line of raw.split("\n")) {
|
|
142
|
+
const trimmed = line.trim();
|
|
143
|
+
if (!trimmed) continue;
|
|
144
|
+
|
|
145
|
+
const fields = trimmed.split("\t");
|
|
146
|
+
if (fields.length === 1) {
|
|
147
|
+
// Sem status — assume modificado
|
|
148
|
+
entries.push({ path: fields[0].trim(), status: "modified" });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const status = mapStatus(fields[0].trim());
|
|
153
|
+
if (fields.length >= 3 && (status === "renamed" || status === "copied")) {
|
|
154
|
+
// R100\told\tnew
|
|
155
|
+
entries.push({ path: fields[2].trim(), oldPath: fields[1].trim(), status });
|
|
156
|
+
} else {
|
|
157
|
+
entries.push({ path: fields[1].trim(), status });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return entries;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Classificação
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export function classifyEntries<TId extends string>(
|
|
168
|
+
entries: FileEntry[],
|
|
169
|
+
config: HoundsmanConfig<TId>,
|
|
170
|
+
): ClassificationResult {
|
|
171
|
+
const watchdogFiles: Record<string, string[]> = {};
|
|
172
|
+
const files: ClassificationResult["files"] = {};
|
|
173
|
+
const unclassified: string[] = [];
|
|
174
|
+
const excluded: string[] = [];
|
|
175
|
+
const deleted: string[] = [];
|
|
176
|
+
// Leitura de conteúdo só é habilitada se algum watchdog declarar regra `content`.
|
|
177
|
+
const needsContent = hasContentRules(config.watchdogs, config.watchdogIds);
|
|
178
|
+
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
const { path, status, oldPath } = entry;
|
|
181
|
+
files[path] = oldPath ? { status, oldPath } : { status };
|
|
182
|
+
|
|
183
|
+
if (isExcludedPath(path, config.exclusions)) {
|
|
184
|
+
excluded.push(path);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (status === "deleted") {
|
|
189
|
+
// Sem conteúdo para revisar — exposto separadamente p/ checagem de breaking change
|
|
190
|
+
deleted.push(path);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const content = needsContent ? readFileContent(path) : undefined;
|
|
195
|
+
const ids: string[] = matchWatchdogs(
|
|
196
|
+
path,
|
|
197
|
+
config.watchdogs,
|
|
198
|
+
config.watchdogIds,
|
|
199
|
+
content,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (ids.length === 0) {
|
|
203
|
+
unclassified.push(path);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const id of ids) {
|
|
208
|
+
watchdogFiles[id] ??= [];
|
|
209
|
+
watchdogFiles[id].push(path);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const watchdogs: Record<string, string[]> = {};
|
|
214
|
+
const stats: Record<string, number> = {};
|
|
215
|
+
for (const id of config.watchdogIds) {
|
|
216
|
+
const list = [...new Set(watchdogFiles[id] ?? [])];
|
|
217
|
+
if (list.length > 0) {
|
|
218
|
+
watchdogs[id] = list;
|
|
219
|
+
stats[id] = list.length;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
watchdogs,
|
|
225
|
+
files,
|
|
226
|
+
unclassified,
|
|
227
|
+
excluded,
|
|
228
|
+
deleted,
|
|
229
|
+
stats,
|
|
230
|
+
excludedCount: excluded.length,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Executa `git diff` entre base e head e classifica os arquivos alterados.
|
|
236
|
+
*/
|
|
237
|
+
export function classifyRange<TId extends string>(
|
|
238
|
+
base: string,
|
|
239
|
+
head: string,
|
|
240
|
+
config: HoundsmanConfig<TId>,
|
|
241
|
+
): ClassificationResult {
|
|
242
|
+
const entries = parseEntries(runGitDiff(base, head));
|
|
243
|
+
const result = classifyEntries(entries, config);
|
|
244
|
+
result.base = base;
|
|
245
|
+
result.head = head;
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel do módulo Jev (pré-análise do houndsman — Passo 1.5).
|
|
3
|
+
*
|
|
4
|
+
* Consumidores importam daqui (`"./jev"`). Tudo relacionado ao Jev vive nesta
|
|
5
|
+
* pasta — nada solto em `core/`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from "./jev-candidates";
|
|
9
|
+
export * from "./jev-client";
|
|
10
|
+
export * from "./jev-detector";
|
|
11
|
+
export * from "./jev-questions";
|
|
12
|
+
export * from "./jev-types";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidatos a achado produzidos pelo passo de pré-análise Jev (Passo 1.5).
|
|
3
|
+
*
|
|
4
|
+
* Um candidato NÃO é um achado: é um indício com confiança calibrada, injetado
|
|
5
|
+
* nos briefs para o subagente **confirmar ou refutar**. A severidade/validação
|
|
6
|
+
* final continua sendo do watchdog (LLM) ou humano.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { SEVERITY_ORDER, type Severity } from "../types";
|
|
11
|
+
|
|
12
|
+
export type JevCandidate = {
|
|
13
|
+
watchdogId: string;
|
|
14
|
+
/** Descrição da regra (deve ser usada como `rule` se confirmada). */
|
|
15
|
+
rule: string;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
file: string;
|
|
18
|
+
/** Probabilidade calibrada retornada pelo modelo (0–1). */
|
|
19
|
+
confidence: number;
|
|
20
|
+
/** Chave da pergunta que originou o candidato. */
|
|
21
|
+
questionKey: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type JevSkippedFile = { file: string; reason: string };
|
|
25
|
+
|
|
26
|
+
export type JevCandidatesFile = {
|
|
27
|
+
available: boolean;
|
|
28
|
+
/** Motivo quando `available: false` (sem chave, erro, etc.). */
|
|
29
|
+
reason?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
threshold?: number;
|
|
32
|
+
analyzedFiles?: number;
|
|
33
|
+
candidates: JevCandidate[];
|
|
34
|
+
skipped?: JevSkippedFile[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const SEVERITY_SET = new Set<string>(SEVERITY_ORDER);
|
|
38
|
+
|
|
39
|
+
/** Persistência do artefato `candidates.json` para um diretório de review. */
|
|
40
|
+
export class JevCandidatesStore {
|
|
41
|
+
/** Limiar padrão de confiança (`noul`) para virar candidato. */
|
|
42
|
+
static readonly THRESHOLD = 0.6;
|
|
43
|
+
|
|
44
|
+
/** Artefato vazio/indisponível (sem chave, erro, `--no-jev`). */
|
|
45
|
+
static unavailable(reason: string): JevCandidatesFile {
|
|
46
|
+
return { available: false, reason, candidates: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private readonly filePath: string;
|
|
50
|
+
/** Ids válidos (null = sem filtro por id). */
|
|
51
|
+
private readonly watchdogIdSet: Set<string> | null;
|
|
52
|
+
|
|
53
|
+
constructor(filePath: string, validIds: readonly string[] = []) {
|
|
54
|
+
this.filePath = filePath;
|
|
55
|
+
this.watchdogIdSet = validIds.length > 0 ? new Set(validIds) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Grava o `candidates.json` (sempre com candidatos válidos apenas). */
|
|
59
|
+
write(data: JevCandidatesFile): void {
|
|
60
|
+
writeFileSync(this.filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Lê o `candidates.json`; null se ausente/inválido. */
|
|
64
|
+
read(): JevCandidatesFile | null {
|
|
65
|
+
if (!existsSync(this.filePath)) return null;
|
|
66
|
+
try {
|
|
67
|
+
const raw = JSON.parse(readFileSync(this.filePath, "utf8")) as Record<
|
|
68
|
+
string,
|
|
69
|
+
unknown
|
|
70
|
+
>;
|
|
71
|
+
const candidates = Array.isArray(raw.candidates)
|
|
72
|
+
? raw.candidates.filter((c) => this.isCandidate(c))
|
|
73
|
+
: [];
|
|
74
|
+
return {
|
|
75
|
+
available: raw.available === true,
|
|
76
|
+
reason: typeof raw.reason === "string" ? raw.reason : undefined,
|
|
77
|
+
model: typeof raw.model === "string" ? raw.model : undefined,
|
|
78
|
+
threshold: typeof raw.threshold === "number" ? raw.threshold : undefined,
|
|
79
|
+
analyzedFiles:
|
|
80
|
+
typeof raw.analyzedFiles === "number" ? raw.analyzedFiles : undefined,
|
|
81
|
+
candidates,
|
|
82
|
+
skipped: Array.isArray(raw.skipped)
|
|
83
|
+
? (raw.skipped as JevSkippedFile[])
|
|
84
|
+
: undefined,
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private isCandidate(value: unknown): value is JevCandidate {
|
|
92
|
+
if (typeof value !== "object" || value === null) return false;
|
|
93
|
+
const c = value as Record<string, unknown>;
|
|
94
|
+
return (
|
|
95
|
+
typeof c.watchdogId === "string" &&
|
|
96
|
+
(this.watchdogIdSet === null || this.watchdogIdSet.has(c.watchdogId)) &&
|
|
97
|
+
typeof c.rule === "string" &&
|
|
98
|
+
typeof c.severity === "string" &&
|
|
99
|
+
SEVERITY_SET.has(c.severity) &&
|
|
100
|
+
typeof c.file === "string" &&
|
|
101
|
+
typeof c.confidence === "number" &&
|
|
102
|
+
typeof c.questionKey === "string"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente HTTP mínimo para a API TypeSafe "System One" (Jev).
|
|
3
|
+
*
|
|
4
|
+
* Implementação própria sobre `axios` (sem SDK oficial) para manter o
|
|
5
|
+
* pipeline de review sem dependências novas de SDK e com tipagem explícita
|
|
6
|
+
* do request/response (`./jev-types`).
|
|
7
|
+
*
|
|
8
|
+
* Config: apenas `TYPESAFE_API_KEY` (via env). Origem e modelo da API são
|
|
9
|
+
* fixos no código (constantes abaixo).
|
|
10
|
+
*
|
|
11
|
+
* Erros são normalizados em `JevError` — o chamador decide falhar-aberto.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import axios, { type AxiosInstance, isAxiosError } from "axios";
|
|
15
|
+
import type { JevEvaluateRequest, JevEvaluateResponse } from "./jev-types";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
18
|
+
const DEFAULT_MODEL = "jev-latest";
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
20
|
+
/** Caminho fixo do endpoint System One. */
|
|
21
|
+
const JEV_PATH = "/v1/systemone";
|
|
22
|
+
|
|
23
|
+
export class JevError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "JevError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type JevClientOptions = {
|
|
31
|
+
/** Chave de API (default: `process.env.TYPESAFE_API_KEY`). */
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
/** Sobreposição da origem da API (default: constante `DEFAULT_BASE_URL`). */
|
|
34
|
+
baseURL?: string;
|
|
35
|
+
/** Sobreposição do modelo (default: constante `DEFAULT_MODEL`). */
|
|
36
|
+
model?: string;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Instância axios injetável (testes). */
|
|
39
|
+
http?: AxiosInstance;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export class JevClient {
|
|
43
|
+
private readonly http: AxiosInstance;
|
|
44
|
+
private readonly apiKey?: string;
|
|
45
|
+
/** Modelo usado quando o request não informa um. */
|
|
46
|
+
readonly model: string;
|
|
47
|
+
|
|
48
|
+
constructor(options: JevClientOptions = {}) {
|
|
49
|
+
this.apiKey = options.apiKey ?? process.env.TYPESAFE_API_KEY;
|
|
50
|
+
this.model = options.model ?? DEFAULT_MODEL;
|
|
51
|
+
const baseURL = options.baseURL ?? DEFAULT_BASE_URL;
|
|
52
|
+
this.http =
|
|
53
|
+
options.http ??
|
|
54
|
+
axios.create({
|
|
55
|
+
baseURL,
|
|
56
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `true` se há chave configurada (sem chave o passo é pulado). */
|
|
61
|
+
isConfigured(): boolean {
|
|
62
|
+
return Boolean(this.apiKey);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Avalia as `questions` contra o `state` em uma única chamada.
|
|
67
|
+
* Lança `JevError` em falha de rede/HTTP/parse — o chamador trata.
|
|
68
|
+
*/
|
|
69
|
+
async evaluate(request: JevEvaluateRequest): Promise<JevEvaluateResponse> {
|
|
70
|
+
if (!this.apiKey) {
|
|
71
|
+
throw new JevError("TYPESAFE_API_KEY não configurada.");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const { data } = await this.http.post<JevEvaluateResponse>(
|
|
75
|
+
JEV_PATH,
|
|
76
|
+
{ ...request, model: request.model ?? this.model },
|
|
77
|
+
{ headers: { Authorization: `Bearer ${this.apiKey}` } },
|
|
78
|
+
);
|
|
79
|
+
return data;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new JevError(JevClient.describeError(error));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Normaliza o erro do axios em mensagem legível (sem expor o payload bruto). */
|
|
86
|
+
private static describeError(error: unknown): string {
|
|
87
|
+
if (isAxiosError(error)) {
|
|
88
|
+
const status = error.response?.status;
|
|
89
|
+
const detail =
|
|
90
|
+
typeof error.response?.data === "string"
|
|
91
|
+
? error.response.data
|
|
92
|
+
: error.response?.data
|
|
93
|
+
? JSON.stringify(error.response.data)
|
|
94
|
+
: error.message;
|
|
95
|
+
return status ? `HTTP ${status}: ${detail}` : detail;
|
|
96
|
+
}
|
|
97
|
+
return error instanceof Error ? error.message : String(error);
|
|
98
|
+
}
|
|
99
|
+
}
|