@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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
|
+
import { collectDiff, type DiffSource } from "../diff-source";
|
|
12
13
|
import type { ClassificationResult } from "../file-classifier";
|
|
13
14
|
import type { WatchdogMeta } from "../types";
|
|
14
15
|
import {
|
|
@@ -25,6 +26,8 @@ export type DetectOptions = {
|
|
|
25
26
|
threshold?: number;
|
|
26
27
|
/** Nº de arquivos avaliados em paralelo. */
|
|
27
28
|
concurrency?: number;
|
|
29
|
+
/** Diff já coletado (evita novo `git diff`); ausente → coleta aqui. */
|
|
30
|
+
diff?: DiffSource;
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
export type DetectResult = {
|
|
@@ -60,6 +63,7 @@ export class JevDetector {
|
|
|
60
63
|
): Promise<DetectResult> {
|
|
61
64
|
const threshold = options.threshold ?? JevCandidatesStore.THRESHOLD;
|
|
62
65
|
const concurrency = options.concurrency ?? JevDetector.DEFAULT_CONCURRENCY;
|
|
66
|
+
const diff = this.resolveDiff(base, head, options.diff);
|
|
63
67
|
|
|
64
68
|
const jobs = this.buildJobs(classification);
|
|
65
69
|
const questionByKey = new Map<string, DerivedQuestion>();
|
|
@@ -76,7 +80,7 @@ export class JevDetector {
|
|
|
76
80
|
for (const dq of job.questions) questions[dq.key] = dq.question;
|
|
77
81
|
|
|
78
82
|
const status = classification.files[job.file]?.status ?? "modified";
|
|
79
|
-
const state = this.buildState(job.file, status,
|
|
83
|
+
const state = this.buildState(job.file, status, diff);
|
|
80
84
|
|
|
81
85
|
try {
|
|
82
86
|
const response = await this.client.evaluate({ state, questions });
|
|
@@ -125,15 +129,24 @@ export class JevDetector {
|
|
|
125
129
|
.filter((job) => job.questions.length > 0);
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Diff para o `state`: reaproveita o injetado ou coleta **uma vez** (antes era
|
|
134
|
+
* 1 `git diff` por arquivo). Tolerante a falha — o Jev é opcional/fail-open.
|
|
135
|
+
*/
|
|
136
|
+
private resolveDiff(base: string, head: string, provided?: DiffSource): DiffSource {
|
|
137
|
+
if (provided) return provided;
|
|
138
|
+
try {
|
|
139
|
+
return collectDiff(base, head);
|
|
140
|
+
} catch {
|
|
141
|
+
return { byFile: new Map<string, string>(), churn: new Map() };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
128
145
|
/** 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 {
|
|
146
|
+
private buildState(filePath: string, status: string, diff: DiffSource): string {
|
|
135
147
|
const header = this.readHeader(filePath);
|
|
136
|
-
const hunk =
|
|
148
|
+
const hunk =
|
|
149
|
+
diff.byFile.get(filePath)?.slice(0, JevDetector.MAX_DIFF_CHARS) ?? "";
|
|
137
150
|
const parts = [`Arquivo: ${filePath} (${status})`];
|
|
138
151
|
if (header) {
|
|
139
152
|
parts.push("--- topo do arquivo (imports/declarações) ---", header);
|
|
@@ -153,17 +166,6 @@ export class JevDetector {
|
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
|
|
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
169
|
/** Arquivo → watchdogs que o cobrem. */
|
|
168
170
|
private static mapFilesToWatchdogs(
|
|
169
171
|
classification: ClassificationResult,
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -17,9 +17,20 @@
|
|
|
17
17
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { join, relative } from "node:path";
|
|
19
19
|
import { renderBrief } from "./brief";
|
|
20
|
-
import
|
|
20
|
+
import {
|
|
21
|
+
type HoundsmanConfig,
|
|
22
|
+
resolveJevConcurrency,
|
|
23
|
+
resolveMaxFilesPerBrief,
|
|
24
|
+
} from "./config";
|
|
25
|
+
import { collectDiff, type DiffSource } from "./diff-source";
|
|
21
26
|
import { discoverDocs } from "./doc-discoverer";
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
type BudgetSelection,
|
|
29
|
+
type ClassificationResult,
|
|
30
|
+
classifyRange,
|
|
31
|
+
selectByBudget,
|
|
32
|
+
} from "./file-classifier";
|
|
33
|
+
import { buildStateFromFindings, FindingsStateStore, fileReader } from "./findings-state";
|
|
23
34
|
import {
|
|
24
35
|
type JevCandidate,
|
|
25
36
|
type JevCandidatesFile,
|
|
@@ -31,8 +42,10 @@ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
|
|
|
31
42
|
import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
|
|
32
43
|
import {
|
|
33
44
|
type ContextSummary,
|
|
45
|
+
type DomainReport,
|
|
34
46
|
dedupeFindings,
|
|
35
47
|
parseWatchdogReport,
|
|
48
|
+
renderDomainReport,
|
|
36
49
|
renderReport,
|
|
37
50
|
sanitizeBranch,
|
|
38
51
|
type WatchdogReport,
|
|
@@ -150,6 +163,22 @@ function writeManifest(
|
|
|
150
163
|
jev: JevCandidatesFile,
|
|
151
164
|
config: HoundsmanConfig,
|
|
152
165
|
): void {
|
|
166
|
+
const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
|
|
167
|
+
let filesOverBudget = 0;
|
|
168
|
+
const watchdogs = activeIds.map((id) => {
|
|
169
|
+
const selection = budgetFor(classification, id, maxFilesPerBrief);
|
|
170
|
+
filesOverBudget += selection.overflow.length;
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
name: findWatchdog(config, id)?.name ?? id,
|
|
174
|
+
files: selection.selected,
|
|
175
|
+
overflow: selection.overflow.length,
|
|
176
|
+
brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
|
|
177
|
+
findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
|
|
178
|
+
status: pending.includes(id) ? "pending" : "done",
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
|
|
153
182
|
const manifest = {
|
|
154
183
|
branch,
|
|
155
184
|
slug: sanitizeBranch(branch),
|
|
@@ -157,14 +186,9 @@ function writeManifest(
|
|
|
157
186
|
head: opts.head,
|
|
158
187
|
generatedAt: new Date().toISOString(),
|
|
159
188
|
outputFormat: OUTPUT_FORMAT_PATH,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
files: classification.watchdogs[id] ?? [],
|
|
164
|
-
brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
|
|
165
|
-
findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
|
|
166
|
-
status: pending.includes(id) ? "pending" : "done",
|
|
167
|
-
})),
|
|
189
|
+
budget: { maxFilesPerBrief, filesOverBudget },
|
|
190
|
+
churn: churnTotals(classification),
|
|
191
|
+
watchdogs,
|
|
168
192
|
context: contextManifest(opts, branch),
|
|
169
193
|
jev: jev.available
|
|
170
194
|
? {
|
|
@@ -186,6 +210,37 @@ function writeManifest(
|
|
|
186
210
|
);
|
|
187
211
|
}
|
|
188
212
|
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Orçamento (triage de PRs grandes)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/** Aplica o orçamento aos arquivos de um watchdog. */
|
|
218
|
+
function budgetFor(
|
|
219
|
+
classification: ClassificationResult,
|
|
220
|
+
id: string,
|
|
221
|
+
maxFiles: number,
|
|
222
|
+
): BudgetSelection {
|
|
223
|
+
return selectByBudget(
|
|
224
|
+
classification.watchdogs[id] ?? [],
|
|
225
|
+
classification.churn,
|
|
226
|
+
maxFiles,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Totais de churn do diff (métricas do manifesto). */
|
|
231
|
+
function churnTotals(classification: ClassificationResult): {
|
|
232
|
+
additions: number;
|
|
233
|
+
deletions: number;
|
|
234
|
+
} {
|
|
235
|
+
let additions = 0;
|
|
236
|
+
let deletions = 0;
|
|
237
|
+
for (const entry of Object.values(classification.churn ?? {})) {
|
|
238
|
+
additions += entry.additions;
|
|
239
|
+
deletions += entry.deletions;
|
|
240
|
+
}
|
|
241
|
+
return { additions, deletions };
|
|
242
|
+
}
|
|
243
|
+
|
|
189
244
|
// ---------------------------------------------------------------------------
|
|
190
245
|
// Briefs
|
|
191
246
|
// ---------------------------------------------------------------------------
|
|
@@ -200,14 +255,17 @@ function writeBriefs(
|
|
|
200
255
|
config: HoundsmanConfig,
|
|
201
256
|
): void {
|
|
202
257
|
const docs = discoverDocs(Object.keys(classification.files), config);
|
|
258
|
+
const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
|
|
203
259
|
for (const id of activeIds) {
|
|
204
260
|
const def = findWatchdog(config, id);
|
|
205
261
|
if (!def) continue;
|
|
206
|
-
const
|
|
262
|
+
const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
|
|
207
263
|
const brief = renderBrief(def, {
|
|
208
264
|
watchdogId: id,
|
|
209
|
-
files,
|
|
265
|
+
files: selected,
|
|
210
266
|
statuses: classification.files,
|
|
267
|
+
churn: classification.churn,
|
|
268
|
+
overflow,
|
|
211
269
|
deleted: classification.deleted,
|
|
212
270
|
branch,
|
|
213
271
|
outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
|
|
@@ -233,6 +291,7 @@ async function resolveCandidates(
|
|
|
233
291
|
classification: ClassificationResult,
|
|
234
292
|
dirs: Paths,
|
|
235
293
|
config: HoundsmanConfig,
|
|
294
|
+
diff: DiffSource,
|
|
236
295
|
): Promise<JevCandidatesFile> {
|
|
237
296
|
const store = new JevCandidatesStore(
|
|
238
297
|
join(dirs.outDir, "candidates.json"),
|
|
@@ -257,7 +316,10 @@ async function resolveCandidates(
|
|
|
257
316
|
client,
|
|
258
317
|
config.watchdogs as Record<string, WatchdogMeta>,
|
|
259
318
|
);
|
|
260
|
-
const result = await detector.detect(classification, opts.base, opts.head
|
|
319
|
+
const result = await detector.detect(classification, opts.base, opts.head, {
|
|
320
|
+
diff,
|
|
321
|
+
concurrency: resolveJevConcurrency(config),
|
|
322
|
+
});
|
|
261
323
|
return save({
|
|
262
324
|
available: true,
|
|
263
325
|
model: result.model,
|
|
@@ -301,6 +363,22 @@ function printDiscovery(
|
|
|
301
363
|
}
|
|
302
364
|
}
|
|
303
365
|
|
|
366
|
+
/** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
|
|
367
|
+
function printBudget(
|
|
368
|
+
classification: ClassificationResult,
|
|
369
|
+
config: HoundsmanConfig,
|
|
370
|
+
): void {
|
|
371
|
+
const maxFiles = resolveMaxFilesPerBrief(config);
|
|
372
|
+
let overflow = 0;
|
|
373
|
+
for (const id of Object.keys(classification.watchdogs)) {
|
|
374
|
+
overflow += budgetFor(classification, id, maxFiles).overflow.length;
|
|
375
|
+
}
|
|
376
|
+
if (overflow === 0) return;
|
|
377
|
+
console.log(
|
|
378
|
+
`Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
304
382
|
/** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
|
|
305
383
|
export async function prepare(
|
|
306
384
|
opts: ReviewOptions,
|
|
@@ -310,13 +388,15 @@ export async function prepare(
|
|
|
310
388
|
): Promise<void> {
|
|
311
389
|
prepareDirs(dirs, opts.clean);
|
|
312
390
|
|
|
313
|
-
|
|
391
|
+
// Um único diff para toda a execução (churn + patch) — antes: 1 spawn por arquivo.
|
|
392
|
+
const diff = collectDiff(opts.base, opts.head);
|
|
393
|
+
const classification = classifyRange(opts.base, opts.head, config, diff);
|
|
314
394
|
writeFileSync(
|
|
315
395
|
join(dirs.outDir, "classification.json"),
|
|
316
396
|
`${JSON.stringify(classification, null, 2)}\n`,
|
|
317
397
|
);
|
|
318
398
|
|
|
319
|
-
const jev = await resolveCandidates(opts, classification, dirs, config);
|
|
399
|
+
const jev = await resolveCandidates(opts, classification, dirs, config, diff);
|
|
320
400
|
|
|
321
401
|
const activeIds = Object.keys(classification.watchdogs);
|
|
322
402
|
if (Object.keys(classification.files).length === 0) {
|
|
@@ -342,6 +422,7 @@ export async function prepare(
|
|
|
342
422
|
config,
|
|
343
423
|
);
|
|
344
424
|
printDiscovery(activeIds, classification, dirs.briefsDir);
|
|
425
|
+
printBudget(classification, config);
|
|
345
426
|
printJevSummary(jev);
|
|
346
427
|
|
|
347
428
|
if (context) {
|
|
@@ -369,7 +450,53 @@ function readClassification(classificationPath: string): ClassificationResult {
|
|
|
369
450
|
`Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
|
|
370
451
|
);
|
|
371
452
|
}
|
|
372
|
-
|
|
453
|
+
const parsed = JSON.parse(
|
|
454
|
+
readFileSync(classificationPath, "utf8"),
|
|
455
|
+
) as ClassificationResult;
|
|
456
|
+
// Compatível com classification.json gerado antes do churn existir.
|
|
457
|
+
parsed.churn ??= {};
|
|
458
|
+
return parsed;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
|
|
463
|
+
* Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
|
|
464
|
+
*/
|
|
465
|
+
function writeDomainReports(
|
|
466
|
+
outDir: string,
|
|
467
|
+
branch: string,
|
|
468
|
+
reports: WatchdogReport[],
|
|
469
|
+
classification: ClassificationResult,
|
|
470
|
+
context: ContextSummary | null,
|
|
471
|
+
nameOf: (id: string) => string,
|
|
472
|
+
): DomainReport[] {
|
|
473
|
+
const domains: DomainReport[] = [];
|
|
474
|
+
for (const report of reports) {
|
|
475
|
+
const findings = dedupeFindings([report]);
|
|
476
|
+
if (findings.length === 0) continue;
|
|
477
|
+
const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
|
|
478
|
+
const expected = classification.watchdogs[report.watchdogId] ?? [];
|
|
479
|
+
writeFileSync(
|
|
480
|
+
join(outDir, file),
|
|
481
|
+
`${renderDomainReport(
|
|
482
|
+
branch,
|
|
483
|
+
report,
|
|
484
|
+
findings,
|
|
485
|
+
expected.length,
|
|
486
|
+
context,
|
|
487
|
+
nameOf,
|
|
488
|
+
)}\n`,
|
|
489
|
+
);
|
|
490
|
+
domains.push({
|
|
491
|
+
id: report.watchdogId,
|
|
492
|
+
name: nameOf(report.watchdogId),
|
|
493
|
+
file,
|
|
494
|
+
findings: findings.length,
|
|
495
|
+
reviewedFiles: report.reviewedFiles.length,
|
|
496
|
+
expectedFiles: expected.length,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return domains;
|
|
373
500
|
}
|
|
374
501
|
|
|
375
502
|
export type FinalizeResult = {
|
|
@@ -416,7 +543,31 @@ export function finalize(
|
|
|
416
543
|
}
|
|
417
544
|
|
|
418
545
|
const merged = dedupeFindings(reports);
|
|
546
|
+
|
|
547
|
+
// Estado de triagem: preserva o que já existia (casando por fingerprint) e
|
|
548
|
+
// anexa o `id` de cada finding aos itens do relatório. A linha é cache —
|
|
549
|
+
// recalculada por `houndsman findings sync` após edições.
|
|
550
|
+
const store = FindingsStateStore.at(opts.dir, branch);
|
|
551
|
+
const state = buildStateFromFindings(merged, {
|
|
552
|
+
branch,
|
|
553
|
+
readFile: fileReader(process.cwd()),
|
|
554
|
+
previous: store.read(),
|
|
555
|
+
});
|
|
556
|
+
store.write(state);
|
|
557
|
+
console.log(
|
|
558
|
+
`Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
|
|
559
|
+
);
|
|
560
|
+
|
|
419
561
|
const context = readContextSummary(contextPaths(opts.dir, branch).summary);
|
|
562
|
+
const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
|
|
563
|
+
const domains = writeDomainReports(
|
|
564
|
+
dirs.outDir,
|
|
565
|
+
branch,
|
|
566
|
+
reports,
|
|
567
|
+
classification,
|
|
568
|
+
context,
|
|
569
|
+
nameOf,
|
|
570
|
+
);
|
|
420
571
|
const markdown = renderReport(
|
|
421
572
|
branch,
|
|
422
573
|
reports,
|
|
@@ -424,7 +575,8 @@ export function finalize(
|
|
|
424
575
|
{ classified: classification.watchdogs },
|
|
425
576
|
context,
|
|
426
577
|
jev.available ? jev.candidates : undefined,
|
|
427
|
-
|
|
578
|
+
nameOf,
|
|
579
|
+
domains,
|
|
428
580
|
);
|
|
429
581
|
const reportPath = join(dirs.outDir, "review.md");
|
|
430
582
|
writeFileSync(reportPath, `${markdown}\n`);
|
package/src/core/paths.ts
CHANGED
|
@@ -28,3 +28,6 @@ export const ARTIFACT_DIR = `${PROJECT_DIR}/.runs`;
|
|
|
28
28
|
|
|
29
29
|
/** Schema de output dos watchdogs, citado nos briefs. */
|
|
30
30
|
export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;
|
|
31
|
+
|
|
32
|
+
/** Estado de triagem dos findings, dentro da pasta da run (`<slug>/state.json`). */
|
|
33
|
+
export const STATE_FILE = "state.json";
|
|
@@ -127,6 +127,20 @@ function checkRulePolarity(config: HoundsmanConfig, warnings: string[]): void {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Aviso para orçamento/concorrência inválidos — o motor cai no default. */
|
|
131
|
+
function checkBudgets(config: HoundsmanConfig, warnings: string[]): void {
|
|
132
|
+
const maxFiles = config.budgets?.maxFilesPerBrief;
|
|
133
|
+
if (maxFiles !== undefined && maxFiles <= 0) {
|
|
134
|
+
warnings.push(
|
|
135
|
+
`budgets.maxFilesPerBrief = ${maxFiles} — não-positivo; usando o default.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const jev = config.concurrency?.jev;
|
|
139
|
+
if (jev !== undefined && jev <= 0) {
|
|
140
|
+
warnings.push(`concurrency.jev = ${jev} — não-positivo; usando o default.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
export function checkRegistry(config: HoundsmanConfig): {
|
|
131
145
|
problems: string[];
|
|
132
146
|
warnings: string[];
|
|
@@ -139,6 +153,7 @@ export function checkRegistry(config: HoundsmanConfig): {
|
|
|
139
153
|
checkClassification(config, problems);
|
|
140
154
|
checkRendering(config, problems);
|
|
141
155
|
checkRulePolarity(config, warnings);
|
|
156
|
+
checkBudgets(config, warnings);
|
|
142
157
|
const jevQuestions = checkJevSpecs(config, problems);
|
|
143
158
|
|
|
144
159
|
return { problems, warnings, jevQuestions };
|
|
@@ -16,6 +16,8 @@ export type WatchdogNameResolver = (watchdogId: string) => string;
|
|
|
16
16
|
const identityName: WatchdogNameResolver = (id) => id;
|
|
17
17
|
|
|
18
18
|
export type Finding = {
|
|
19
|
+
/** Id curto do finding (preenchido no `--finalize`; usado pela triagem). */
|
|
20
|
+
id?: string;
|
|
19
21
|
rule: string;
|
|
20
22
|
severity: Severity;
|
|
21
23
|
file: string;
|
|
@@ -206,8 +208,9 @@ function watchdogName(nameOf: WatchdogNameResolver, id: string): string {
|
|
|
206
208
|
function renderFinding(finding: MergedFinding, nameOf: WatchdogNameResolver): string[] {
|
|
207
209
|
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
208
210
|
const watchdogs = finding.watchdogs.map((r) => watchdogName(nameOf, r)).join(", ");
|
|
211
|
+
const id = finding.id ? ` · \`${finding.id}\`` : "";
|
|
209
212
|
const lines = [
|
|
210
|
-
`- **${finding.rule}** — \`${location}\` · _${watchdogs}_`,
|
|
213
|
+
`- **${finding.rule}** — \`${location}\` · _${watchdogs}_${id}`,
|
|
211
214
|
` ${finding.message}`,
|
|
212
215
|
` **Ação:** ${finding.action}`,
|
|
213
216
|
];
|
|
@@ -284,6 +287,112 @@ function renderConclusion(counts: Record<Severity, number>): string[] {
|
|
|
284
287
|
];
|
|
285
288
|
}
|
|
286
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Agrupa os achados por arquivo (navegabilidade em PRs grandes) e renderiza cada
|
|
292
|
+
* bloco. O `merged` já vem ordenado por severidade/arquivo/linha.
|
|
293
|
+
*/
|
|
294
|
+
function renderFindingsByFile(
|
|
295
|
+
findings: readonly MergedFinding[],
|
|
296
|
+
nameOf: WatchdogNameResolver,
|
|
297
|
+
): string[] {
|
|
298
|
+
const byFile = new Map<string, MergedFinding[]>();
|
|
299
|
+
for (const finding of findings) {
|
|
300
|
+
const list = byFile.get(finding.file);
|
|
301
|
+
if (list) list.push(finding);
|
|
302
|
+
else byFile.set(finding.file, [finding]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const lines: string[] = [];
|
|
306
|
+
for (const [file, items] of byFile) {
|
|
307
|
+
lines.push(`### \`${file}\``, "");
|
|
308
|
+
for (const item of items) lines.push(...renderFinding(item, nameOf));
|
|
309
|
+
}
|
|
310
|
+
return lines;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Seções por severidade (❌ > ⚠️ > 💡 > 🔎), com achados agrupados por arquivo. */
|
|
314
|
+
function renderFindingsSections(
|
|
315
|
+
merged: readonly MergedFinding[],
|
|
316
|
+
nameOf: WatchdogNameResolver,
|
|
317
|
+
): string[] {
|
|
318
|
+
const lines: string[] = [];
|
|
319
|
+
for (const severity of SEVERITY_ORDER) {
|
|
320
|
+
const items = merged.filter((f) => f.severity === severity);
|
|
321
|
+
if (items.length === 0) continue;
|
|
322
|
+
lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
|
|
323
|
+
lines.push(...renderFindingsByFile(items, nameOf));
|
|
324
|
+
}
|
|
325
|
+
return lines;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Relatório de domínio gravado à parte (um por watchdog com achados). */
|
|
329
|
+
export type DomainReport = {
|
|
330
|
+
id: string;
|
|
331
|
+
name: string;
|
|
332
|
+
/** Nome do arquivo do relatório (relativo ao diretório de artefatos). */
|
|
333
|
+
file: string;
|
|
334
|
+
findings: number;
|
|
335
|
+
reviewedFiles: number;
|
|
336
|
+
expectedFiles: number;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Índice dos relatórios por domínio (seção do `review.md` consolidado). */
|
|
340
|
+
function renderDomainIndex(domains: readonly DomainReport[]): string[] {
|
|
341
|
+
if (domains.length === 0) return [];
|
|
342
|
+
const lines = [
|
|
343
|
+
"## Relatórios por domínio",
|
|
344
|
+
"",
|
|
345
|
+
"| Watchdog | Achados | Revisados | Relatório |",
|
|
346
|
+
"|---|---|---|---|",
|
|
347
|
+
];
|
|
348
|
+
for (const domain of domains) {
|
|
349
|
+
lines.push(
|
|
350
|
+
`| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | [\`${domain.file}\`](${domain.file}) |`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
lines.push("");
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Relatório isolado de um watchdog — mantém o `review.md` enxuto em PRs grandes.
|
|
359
|
+
*/
|
|
360
|
+
export function renderDomainReport(
|
|
361
|
+
branch: string,
|
|
362
|
+
report: WatchdogReport,
|
|
363
|
+
merged: MergedFinding[],
|
|
364
|
+
expectedFiles: number,
|
|
365
|
+
context?: ContextSummary | null,
|
|
366
|
+
nameOf: WatchdogNameResolver = identityName,
|
|
367
|
+
): string {
|
|
368
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
369
|
+
const name = watchdogName(nameOf, report.watchdogId);
|
|
370
|
+
const lines: string[] = [
|
|
371
|
+
`# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
|
|
372
|
+
"",
|
|
373
|
+
`**Data:** ${now}`,
|
|
374
|
+
`**Arquivos revisados:** ${report.reviewedFiles.length}/${expectedFiles}`,
|
|
375
|
+
`**Achados:** ${merged.length}`,
|
|
376
|
+
"",
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
if (context?.available) lines.push(...renderContext(context));
|
|
380
|
+
|
|
381
|
+
lines.push(...renderFindingsSections(merged, nameOf));
|
|
382
|
+
if (merged.length === 0) lines.push("Nenhum achado. ✅", "");
|
|
383
|
+
|
|
384
|
+
if (report.skipped.length > 0) {
|
|
385
|
+
lines.push(
|
|
386
|
+
"## Não revisados",
|
|
387
|
+
"",
|
|
388
|
+
...report.skipped.map((item) => `- \`${item}\``),
|
|
389
|
+
"",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return lines.join("\n");
|
|
394
|
+
}
|
|
395
|
+
|
|
287
396
|
/** Seção de contexto da PR (Objetivo / Motivação / Resumo das alterações). */
|
|
288
397
|
function renderContext(context: ContextSummary): string[] {
|
|
289
398
|
const lines = ["## Contexto da PR", ""];
|
|
@@ -313,6 +422,7 @@ export function renderReport(
|
|
|
313
422
|
context?: ContextSummary | null,
|
|
314
423
|
candidates?: JevCandidate[],
|
|
315
424
|
nameOf: WatchdogNameResolver = identityName,
|
|
425
|
+
domains: readonly DomainReport[] = [],
|
|
316
426
|
): string {
|
|
317
427
|
const now = new Date().toISOString().slice(0, 10);
|
|
318
428
|
const counts = {
|
|
@@ -333,14 +443,7 @@ export function renderReport(
|
|
|
333
443
|
|
|
334
444
|
if (context) lines.push(...renderContext(context));
|
|
335
445
|
|
|
336
|
-
|
|
337
|
-
const items = merged.filter((f) => f.severity === severity);
|
|
338
|
-
if (items.length === 0) continue;
|
|
339
|
-
lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
|
|
340
|
-
for (const finding of items) {
|
|
341
|
-
lines.push(...renderFinding(finding, nameOf));
|
|
342
|
-
}
|
|
343
|
-
}
|
|
446
|
+
lines.push(...renderFindingsSections(merged, nameOf));
|
|
344
447
|
|
|
345
448
|
if (merged.length === 0) {
|
|
346
449
|
lines.push("Nenhum achado. ✅", "");
|
|
@@ -350,6 +453,7 @@ export function renderReport(
|
|
|
350
453
|
lines.push(...renderUnconfirmedCandidates(candidates, merged));
|
|
351
454
|
}
|
|
352
455
|
|
|
456
|
+
lines.push(...renderDomainIndex(domains));
|
|
353
457
|
lines.push(...renderCoverage(reports, coverage, nameOf));
|
|
354
458
|
lines.push(...renderConclusion(counts));
|
|
355
459
|
|
package/src/core/types.ts
CHANGED
|
@@ -79,6 +79,12 @@ export type FileStatus =
|
|
|
79
79
|
/** Status por arquivo, conforme o `classification.json`. */
|
|
80
80
|
export type FileStatusMap = Record<string, { status: FileStatus; oldPath?: string }>;
|
|
81
81
|
|
|
82
|
+
/** Linhas adicionadas/removidas de um arquivo no diff (churn). */
|
|
83
|
+
export type FileChurn = { additions: number; deletions: number };
|
|
84
|
+
|
|
85
|
+
/** Churn por caminho de arquivo (chave = caminho novo). */
|
|
86
|
+
export type ChurnMap = Record<string, FileChurn>;
|
|
87
|
+
|
|
82
88
|
/** Contexto da PR injetado no brief (estrutural — sem dependência de runtime). */
|
|
83
89
|
export type WatchdogRenderContext = {
|
|
84
90
|
objective?: string;
|
|
@@ -99,6 +105,10 @@ export type WatchdogRenderArgs = {
|
|
|
99
105
|
watchdogId: string;
|
|
100
106
|
files: readonly string[];
|
|
101
107
|
statuses: FileStatusMap;
|
|
108
|
+
/** Churn por arquivo — insumo para priorizar a leitura (PRs grandes). */
|
|
109
|
+
churn?: ChurnMap;
|
|
110
|
+
/** Arquivos do domínio fora do orçamento — NÃO revisar (registrar em `skipped`). */
|
|
111
|
+
overflow?: readonly string[];
|
|
102
112
|
deleted: readonly string[];
|
|
103
113
|
branch: string;
|
|
104
114
|
outputPath: string;
|