@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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcomando `findings` — triagem dos findings da revisão (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Lê/grava o `state.json` da run (`findings-state.ts`). Devolve o texto pronto
|
|
5
|
+
* (markdown ou JSON) + exit code; o CLI decide o log/exit. O agente do usuário
|
|
6
|
+
* usa `list`/`set`/`done` para interagir sem abrir o JSON à mão.
|
|
7
|
+
*
|
|
8
|
+
* Se o `state.json` não existir, é re-construído a partir de
|
|
9
|
+
* `findings/*.json` + `classification.json` (não exige `--finalize`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type HoundsmanConfig, resolveArtifactDir } from "../config";
|
|
13
|
+
import {
|
|
14
|
+
applyFindingUpdate,
|
|
15
|
+
buildStateFromRunDir,
|
|
16
|
+
FINDING_DECISIONS,
|
|
17
|
+
FINDING_EXEC_STATES,
|
|
18
|
+
type FindingDecision,
|
|
19
|
+
type FindingExecState,
|
|
20
|
+
type FindingState,
|
|
21
|
+
type FindingsFilter,
|
|
22
|
+
type FindingsStateFile,
|
|
23
|
+
FindingsStateStore,
|
|
24
|
+
type FindingsSummary,
|
|
25
|
+
type FindingUpdate,
|
|
26
|
+
fileReader,
|
|
27
|
+
filterFindings,
|
|
28
|
+
runDir,
|
|
29
|
+
summarize,
|
|
30
|
+
syncState,
|
|
31
|
+
} from "../findings-state";
|
|
32
|
+
import { relFromRoot } from "../paths";
|
|
33
|
+
import { currentBranch } from "../pr-context";
|
|
34
|
+
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
|
|
35
|
+
|
|
36
|
+
export type FindingsCommandResult = {
|
|
37
|
+
output: string;
|
|
38
|
+
exitCode: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const VALUE_FLAGS = new Set([
|
|
42
|
+
"--decision",
|
|
43
|
+
"--exec",
|
|
44
|
+
"--watchdog",
|
|
45
|
+
"--severity",
|
|
46
|
+
"--file",
|
|
47
|
+
"--branch",
|
|
48
|
+
"--dir",
|
|
49
|
+
"--limit",
|
|
50
|
+
"--comment",
|
|
51
|
+
"--by",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
type ParsedArgs = {
|
|
55
|
+
sub?: string;
|
|
56
|
+
positionals: string[];
|
|
57
|
+
values: Record<string, string>;
|
|
58
|
+
bools: Set<string>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Parse simples: primeiro não-flag = sub; demais = positionais. */
|
|
62
|
+
function parseArgs(args: string[]): ParsedArgs {
|
|
63
|
+
const parsed: ParsedArgs = { positionals: [], values: {}, bools: new Set() };
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const arg = args[i];
|
|
66
|
+
if (arg.startsWith("--")) {
|
|
67
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
68
|
+
const value = args[i + 1];
|
|
69
|
+
if (value !== undefined) parsed.values[arg] = value;
|
|
70
|
+
i++;
|
|
71
|
+
} else {
|
|
72
|
+
parsed.bools.add(arg);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!parsed.sub) parsed.sub = arg;
|
|
77
|
+
else parsed.positionals.push(arg);
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Diretório da run + branch resolvida. */
|
|
83
|
+
function resolveDir(
|
|
84
|
+
config: HoundsmanConfig,
|
|
85
|
+
parsed: ParsedArgs,
|
|
86
|
+
): { dir: string; branch: string } {
|
|
87
|
+
return {
|
|
88
|
+
dir: parsed.values["--dir"] ?? resolveArtifactDir(config),
|
|
89
|
+
branch: parsed.values["--branch"] ?? currentBranch(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Lê o estado da run; reconstrói dos findings se ausente. Lança se impossível. */
|
|
94
|
+
function loadState(config: HoundsmanConfig, parsed: ParsedArgs): FindingsStateFile {
|
|
95
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
96
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
97
|
+
const existing = store.read();
|
|
98
|
+
if (existing) return existing;
|
|
99
|
+
|
|
100
|
+
const built = buildStateFromRunDir(
|
|
101
|
+
runDir(dir, branch),
|
|
102
|
+
branch,
|
|
103
|
+
fileReader(process.cwd()),
|
|
104
|
+
);
|
|
105
|
+
if (!built) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Nenhum estado de triagem para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
store.write(built);
|
|
111
|
+
return built;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Render
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function decisionLabel(decision: FindingDecision): string {
|
|
119
|
+
return decision;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Uma linha compacta por finding (uso no agente). */
|
|
123
|
+
function renderFindingLine(finding: FindingState): string[] {
|
|
124
|
+
const emoji = SEVERITY_EMOJI[finding.severity];
|
|
125
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
126
|
+
const flags = `${decisionLabel(finding.decision)}/${finding.execState}`;
|
|
127
|
+
const count = finding.count > 1 ? ` ×${finding.count}` : "";
|
|
128
|
+
const lines = [
|
|
129
|
+
`- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
|
|
130
|
+
` ${finding.message}`,
|
|
131
|
+
` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
|
|
132
|
+
];
|
|
133
|
+
if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderList(state: FindingsStateFile, findings: FindingState[]): string {
|
|
138
|
+
if (findings.length === 0) return "Nenhum finding para o filtro informado.";
|
|
139
|
+
const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
|
|
140
|
+
for (const finding of findings) lines.push(...renderFindingLine(finding));
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderSummary(state: FindingsStateFile, summary: FindingsSummary): string {
|
|
145
|
+
const decisions = FINDING_DECISIONS.map((d) => `${d} ${summary.byDecision[d]}`).join(
|
|
146
|
+
" · ",
|
|
147
|
+
);
|
|
148
|
+
const exec = FINDING_EXEC_STATES.map((e) => `${e} ${summary.byExecState[e]}`).join(
|
|
149
|
+
" · ",
|
|
150
|
+
);
|
|
151
|
+
const severities = SEVERITY_ORDER.map((s) => `${s} ${summary.bySeverity[s]}`).join(
|
|
152
|
+
" · ",
|
|
153
|
+
);
|
|
154
|
+
return [
|
|
155
|
+
`# Triagem — ${state.branch}`,
|
|
156
|
+
"",
|
|
157
|
+
`Total: ${summary.total} (presentes: ${summary.present})`,
|
|
158
|
+
`Decisões: ${decisions}`,
|
|
159
|
+
`Execução: ${exec}`,
|
|
160
|
+
`Severidade: ${severities}`,
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Execução
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function parseDecision(raw: string | undefined): FindingDecision | undefined {
|
|
169
|
+
if (!raw) return undefined;
|
|
170
|
+
if (!FINDING_DECISIONS.includes(raw as FindingDecision)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`--decision inválido: '${raw}'. Use: ${FINDING_DECISIONS.join(", ")}.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return raw as FindingDecision;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseExec(raw: string | undefined): FindingExecState | undefined {
|
|
179
|
+
if (!raw) return undefined;
|
|
180
|
+
if (!FINDING_EXEC_STATES.includes(raw as FindingExecState)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`--exec inválido: '${raw}'. Use: ${FINDING_EXEC_STATES.join(", ")}.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return raw as FindingExecState;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function filterFrom(parsed: ParsedArgs): FindingsFilter {
|
|
189
|
+
const severity = parsed.values["--severity"];
|
|
190
|
+
return {
|
|
191
|
+
decision: parseDecision(parsed.values["--decision"]),
|
|
192
|
+
execState: parseExec(parsed.values["--exec"]),
|
|
193
|
+
watchdog: parsed.values["--watchdog"],
|
|
194
|
+
severity: severity ? (severity as Severity) : undefined,
|
|
195
|
+
file: parsed.values["--file"],
|
|
196
|
+
includeAbsent: parsed.bools.has("--all"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Aplica uma atualização a um finding e persiste. */
|
|
201
|
+
function updateOne(
|
|
202
|
+
config: HoundsmanConfig,
|
|
203
|
+
parsed: ParsedArgs,
|
|
204
|
+
id: string,
|
|
205
|
+
update: FindingUpdate,
|
|
206
|
+
): FindingState {
|
|
207
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
208
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
209
|
+
let updated: FindingState | undefined;
|
|
210
|
+
store.update((state) => {
|
|
211
|
+
const finding = state.findings[id];
|
|
212
|
+
if (!finding) return;
|
|
213
|
+
updated = applyFindingUpdate(finding, update);
|
|
214
|
+
});
|
|
215
|
+
if (!updated) throw new Error(`Finding '${id}' não encontrado.`);
|
|
216
|
+
return updated;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Aplica a mesma atualização a vários ids (done/reset). */
|
|
220
|
+
function updateMany(
|
|
221
|
+
config: HoundsmanConfig,
|
|
222
|
+
parsed: ParsedArgs,
|
|
223
|
+
ids: string[],
|
|
224
|
+
update: FindingUpdate,
|
|
225
|
+
): FindingState[] {
|
|
226
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
227
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
228
|
+
const updated: FindingState[] = [];
|
|
229
|
+
store.update((state) => {
|
|
230
|
+
for (const id of ids) {
|
|
231
|
+
const finding = state.findings[id];
|
|
232
|
+
if (!finding) continue;
|
|
233
|
+
updated.push(applyFindingUpdate(finding, update));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const missing = ids.filter((id) => !updated.some((f) => f.id === id));
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
throw new Error(`Findings não encontrados: ${missing.join(", ")}.`);
|
|
239
|
+
}
|
|
240
|
+
return updated;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Executa um subcomando de `findings` e devolve o texto + exit code. */
|
|
244
|
+
export function runFindingsCommand(
|
|
245
|
+
config: HoundsmanConfig,
|
|
246
|
+
args: string[],
|
|
247
|
+
): FindingsCommandResult {
|
|
248
|
+
const parsed = parseArgs(args);
|
|
249
|
+
try {
|
|
250
|
+
return execute(config, parsed);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return {
|
|
253
|
+
output: error instanceof Error ? error.message : String(error),
|
|
254
|
+
exitCode: 1,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandResult {
|
|
260
|
+
switch (parsed.sub ?? "list") {
|
|
261
|
+
case "list": {
|
|
262
|
+
const state = loadState(config, parsed);
|
|
263
|
+
const filtered = filterFindings(state, filterFrom(parsed));
|
|
264
|
+
const limit = Number(parsed.values["--limit"]);
|
|
265
|
+
const findings =
|
|
266
|
+
Number.isFinite(limit) && limit > 0 ? filtered.slice(0, limit) : filtered;
|
|
267
|
+
return {
|
|
268
|
+
output: parsed.bools.has("--json")
|
|
269
|
+
? JSON.stringify(findings, null, 2)
|
|
270
|
+
: renderList(state, findings),
|
|
271
|
+
exitCode: 0,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case "show": {
|
|
275
|
+
const state = loadState(config, parsed);
|
|
276
|
+
const id = parsed.positionals[0];
|
|
277
|
+
if (!id) throw new Error("Informe o id: houndsman findings show <id>");
|
|
278
|
+
const finding = state.findings[id];
|
|
279
|
+
if (!finding) throw new Error(`Finding '${id}' não encontrado.`);
|
|
280
|
+
return {
|
|
281
|
+
output: parsed.bools.has("--json")
|
|
282
|
+
? JSON.stringify(finding, null, 2)
|
|
283
|
+
: renderList(state, [finding]),
|
|
284
|
+
exitCode: 0,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
case "set": {
|
|
288
|
+
const id = parsed.positionals[0];
|
|
289
|
+
if (!id) throw new Error("Informe o id: houndsman findings set <id> [flags]");
|
|
290
|
+
const update = updateFrom(parsed);
|
|
291
|
+
if (!hasUpdate(update)) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
"Nada para atualizar (use --decision, --exec ou --comment).",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const finding = updateOne(config, parsed, id, update);
|
|
297
|
+
return {
|
|
298
|
+
output: parsed.bools.has("--json")
|
|
299
|
+
? JSON.stringify(finding, null, 2)
|
|
300
|
+
: `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
|
|
301
|
+
exitCode: 0,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
case "done": {
|
|
305
|
+
const ids = requireIds(parsed, "done");
|
|
306
|
+
const updated = updateMany(config, parsed, ids, { execState: "done" });
|
|
307
|
+
return {
|
|
308
|
+
output: `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
|
|
309
|
+
exitCode: 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
case "reset": {
|
|
313
|
+
const ids = requireIds(parsed, "reset");
|
|
314
|
+
const updated = updateMany(config, parsed, ids, {
|
|
315
|
+
decision: "pending",
|
|
316
|
+
execState: "pending",
|
|
317
|
+
comment: "",
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
output: `Triagem reiniciada: ${updated.map((f) => f.id).join(", ")}.`,
|
|
321
|
+
exitCode: 0,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
case "sync": {
|
|
325
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
326
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
327
|
+
const state = loadState(config, parsed);
|
|
328
|
+
const result = syncState(state, fileReader(process.cwd()));
|
|
329
|
+
store.write(state);
|
|
330
|
+
const output = parsed.bools.has("--json")
|
|
331
|
+
? JSON.stringify(result, null, 2)
|
|
332
|
+
: [
|
|
333
|
+
`Sync — ${branch}: ${result.ok} ok, ${result.moved.length} movido(s), ${result.stale.length} stale.`,
|
|
334
|
+
result.moved.length > 0
|
|
335
|
+
? `Movidos: ${result.moved.join(", ")}`
|
|
336
|
+
: "",
|
|
337
|
+
result.stale.length > 0
|
|
338
|
+
? `Stale: ${result.stale.join(", ")}`
|
|
339
|
+
: "",
|
|
340
|
+
]
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join("\n");
|
|
343
|
+
return { output, exitCode: 0 };
|
|
344
|
+
}
|
|
345
|
+
case "summary": {
|
|
346
|
+
const state = loadState(config, parsed);
|
|
347
|
+
const summary = summarize(state);
|
|
348
|
+
return {
|
|
349
|
+
output: parsed.bools.has("--json")
|
|
350
|
+
? JSON.stringify(summary, null, 2)
|
|
351
|
+
: renderSummary(state, summary),
|
|
352
|
+
exitCode: 0,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
case "path": {
|
|
356
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
357
|
+
const path = FindingsStateStore.at(dir, branch).filePath;
|
|
358
|
+
return { output: relFromRoot(path), exitCode: 0 };
|
|
359
|
+
}
|
|
360
|
+
default:
|
|
361
|
+
return {
|
|
362
|
+
output: `Subcomando '${parsed.sub}' desconhecido. Use: list, show, set, done, reset, sync, summary, path.`,
|
|
363
|
+
exitCode: 1,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function updateFrom(parsed: ParsedArgs): FindingUpdate {
|
|
369
|
+
return {
|
|
370
|
+
decision: parseDecision(parsed.values["--decision"]),
|
|
371
|
+
execState: parseExec(parsed.values["--exec"]),
|
|
372
|
+
comment: parsed.values["--comment"],
|
|
373
|
+
by: parsed.values["--by"],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function hasUpdate(update: FindingUpdate): boolean {
|
|
378
|
+
return (
|
|
379
|
+
update.decision !== undefined ||
|
|
380
|
+
update.execState !== undefined ||
|
|
381
|
+
update.comment !== undefined
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function requireIds(parsed: ParsedArgs, sub: string): string[] {
|
|
386
|
+
if (parsed.positionals.length === 0) {
|
|
387
|
+
throw new Error(`Informe ao menos um id: houndsman findings ${sub} <id...>`);
|
|
388
|
+
}
|
|
389
|
+
return parsed.positionals;
|
|
390
|
+
}
|
|
@@ -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. */
|
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. Substitui os N spawns de
|
|
6
|
+
* `git diff -- <arquivo>` (antes feitos por arquivo, no `jev-detector`): o custo
|
|
7
|
+
* deixa de crescer com o número de arquivos da PR.
|
|
8
|
+
*
|
|
9
|
+
* Usado por `orchestrator.prepare` (churn para a classificação + patch para o
|
|
10
|
+
* Jev) e por `classifyRange` (fallback standalone).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ChurnMap, FileChurn } from "./types";
|
|
14
|
+
|
|
15
|
+
export type DiffSource = {
|
|
16
|
+
/** path → patch do arquivo (header + hunks), truncado por `maxPerFileChars`. */
|
|
17
|
+
byFile: Map<string, string>;
|
|
18
|
+
/** path (novo) → linhas adicionadas/removidas. */
|
|
19
|
+
churn: Map<string, FileChurn>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type CollectDiffOptions = {
|
|
23
|
+
/** Teto de caracteres por arquivo no patch (default: 8_000). */
|
|
24
|
+
maxPerFileChars?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
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
|
+
}
|