@payconductor/houndsman 0.1.0 → 0.1.3
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 +68 -89
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +101 -10
- package/src/core/assets.ts +11 -0
- package/src/core/brief.ts +27 -5
- package/src/core/commands/findings.ts +618 -0
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +33 -44
- 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 +556 -0
- package/src/core/fix-batch.ts +236 -0
- package/src/core/git-revision.ts +105 -0
- package/src/core/jev/index.ts +1 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +362 -23
- package/src/core/paths.ts +9 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +118 -9
- package/src/core/run-status.ts +123 -0
- package/src/core/types.ts +10 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +67 -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 +52 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +94 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +52 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +376 -0
- package/src/skill/AUDIT-PROTOCOL.md +2 -2
- package/src/skill/README.md +43 -11
- package/src/skill/SETUP.md +16 -4
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +83 -33
- package/src/skill/{houndsman-update.md → train.md} +12 -12
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +476 -0
- package/src/tests/findings-state.test.ts +325 -0
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +242 -0
- package/src/tests/verify.test.ts +170 -0
package/src/core/orchestrator.ts
CHANGED
|
@@ -17,9 +17,27 @@
|
|
|
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 { churnToRecord, collectDiff, type DiffSource } from "./diff-source";
|
|
21
26
|
import { discoverDocs } from "./doc-discoverer";
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
type BudgetSelection,
|
|
29
|
+
type ClassificationResult,
|
|
30
|
+
classifyEntries,
|
|
31
|
+
classifyRange,
|
|
32
|
+
selectByBudget,
|
|
33
|
+
} from "./file-classifier";
|
|
34
|
+
import {
|
|
35
|
+
buildStateFromFindings,
|
|
36
|
+
type FindingsStateFile,
|
|
37
|
+
FindingsStateStore,
|
|
38
|
+
fileReader,
|
|
39
|
+
} from "./findings-state";
|
|
40
|
+
import { collectDiffSince, diffEntries, isAncestor, resolveSha } from "./git-revision";
|
|
23
41
|
import {
|
|
24
42
|
type JevCandidate,
|
|
25
43
|
type JevCandidatesFile,
|
|
@@ -31,13 +49,17 @@ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
|
|
|
31
49
|
import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
|
|
32
50
|
import {
|
|
33
51
|
type ContextSummary,
|
|
52
|
+
type DomainReport,
|
|
34
53
|
dedupeFindings,
|
|
54
|
+
type MergedFinding,
|
|
35
55
|
parseWatchdogReport,
|
|
56
|
+
renderDomainReport,
|
|
36
57
|
renderReport,
|
|
37
58
|
sanitizeBranch,
|
|
38
59
|
type WatchdogReport,
|
|
39
60
|
} from "./report-builder";
|
|
40
|
-
import
|
|
61
|
+
import { writeRunStatus } from "./run-status";
|
|
62
|
+
import { severityRank, type WatchdogMeta } from "./types";
|
|
41
63
|
import { findWatchdog } from "./watchdog-registry";
|
|
42
64
|
|
|
43
65
|
export type ReviewOptions = {
|
|
@@ -49,6 +71,8 @@ export type ReviewOptions = {
|
|
|
49
71
|
failOnBlocking: boolean;
|
|
50
72
|
clean: boolean;
|
|
51
73
|
noJev: boolean;
|
|
74
|
+
/** Força review completo (ignora o modo incremental). */
|
|
75
|
+
full: boolean;
|
|
52
76
|
};
|
|
53
77
|
|
|
54
78
|
/** Valores default das opções (o CLI sobrepõe com as flags). */
|
|
@@ -61,6 +85,7 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
|
|
|
61
85
|
failOnBlocking: false,
|
|
62
86
|
clean: false,
|
|
63
87
|
noJev: false,
|
|
88
|
+
full: false,
|
|
64
89
|
};
|
|
65
90
|
}
|
|
66
91
|
|
|
@@ -96,6 +121,142 @@ function prepareDirs(dirs: Paths, clean: boolean): void {
|
|
|
96
121
|
}
|
|
97
122
|
}
|
|
98
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Escopo da revisão (completo vs incremental)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Classificação enriquecida gravada no `classification.json` (modo + revisão + carry-over). */
|
|
129
|
+
type RunClassification = ClassificationResult & {
|
|
130
|
+
/** Commit revisado (head do diff). */
|
|
131
|
+
headSha?: string;
|
|
132
|
+
/** Modo do prepare: completo ou incremental (delta desde a última revisão). */
|
|
133
|
+
mode: "full" | "incremental";
|
|
134
|
+
/** Commit da última revisão (só no incremental). */
|
|
135
|
+
reviewedFrom?: string;
|
|
136
|
+
/** Findings preservados do review anterior (arquivos inalterados). */
|
|
137
|
+
carryover?: MergedFinding[];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** SHA curto para logs (7 chars, como o git). */
|
|
141
|
+
function shortSha(sha: string): string {
|
|
142
|
+
return sha.slice(0, 7);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Chave de dedupe de finding: `(file, line, rule)` — igual ao `dedupeFindings`. */
|
|
146
|
+
function findingKey(finding: Pick<MergedFinding, "file" | "line" | "rule">): string {
|
|
147
|
+
return `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Aviso no topo do `review.md` quando a revisão foi incremental (senão vazio). */
|
|
151
|
+
function incrementalNote(classification: RunClassification): string {
|
|
152
|
+
if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
|
|
153
|
+
const carried = classification.carryover?.length ?? 0;
|
|
154
|
+
return (
|
|
155
|
+
`> **Revisão incremental** — desde \`${shortSha(classification.reviewedFrom)}\`; ` +
|
|
156
|
+
`${Object.keys(classification.files).length} arquivo(s) alterado(s), ` +
|
|
157
|
+
`${carried} finding(s) carregado(s) do review anterior.\n\n`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Findings do review anterior em arquivos inalterados — carregados sem
|
|
163
|
+
* re-revisão (carry-over). Preserva o `foundAtSha` para o diff focado do verify.
|
|
164
|
+
*/
|
|
165
|
+
function buildCarryover(
|
|
166
|
+
previous: FindingsStateFile | null,
|
|
167
|
+
changedPaths: Set<string>,
|
|
168
|
+
): MergedFinding[] {
|
|
169
|
+
if (!previous) return [];
|
|
170
|
+
return Object.values(previous.findings)
|
|
171
|
+
.filter((finding) => finding.present && !changedPaths.has(finding.file))
|
|
172
|
+
.map((finding) => ({
|
|
173
|
+
id: finding.id,
|
|
174
|
+
rule: finding.rule,
|
|
175
|
+
severity: finding.severity,
|
|
176
|
+
file: finding.file,
|
|
177
|
+
line: finding.line ?? undefined,
|
|
178
|
+
message: finding.message,
|
|
179
|
+
action: finding.action,
|
|
180
|
+
evidence: finding.evidence,
|
|
181
|
+
foundAtSha: finding.foundAtSha,
|
|
182
|
+
watchdogs: [...finding.watchdogIds],
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Une os findings re-reportados com os carry-over (preserva ids do estado anterior). */
|
|
187
|
+
function mergeCarryover(
|
|
188
|
+
merged: MergedFinding[],
|
|
189
|
+
carryover: readonly MergedFinding[],
|
|
190
|
+
): MergedFinding[] {
|
|
191
|
+
const byKey = new Map(merged.map((finding) => [findingKey(finding), finding]));
|
|
192
|
+
for (const carried of carryover) {
|
|
193
|
+
const existing = byKey.get(findingKey(carried));
|
|
194
|
+
if (existing) {
|
|
195
|
+
for (const watchdog of carried.watchdogs) {
|
|
196
|
+
if (!existing.watchdogs.includes(watchdog))
|
|
197
|
+
existing.watchdogs.push(watchdog);
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
byKey.set(findingKey(carried), carried);
|
|
202
|
+
merged.push(carried);
|
|
203
|
+
}
|
|
204
|
+
merged.sort((a, b) => {
|
|
205
|
+
const bySeverity = severityRank(a.severity) - severityRank(b.severity);
|
|
206
|
+
if (bySeverity !== 0) return bySeverity;
|
|
207
|
+
if (a.file !== b.file) return a.file.localeCompare(b.file);
|
|
208
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
209
|
+
});
|
|
210
|
+
return merged;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Decide entre review completo e incremental. Incremental só quando há um
|
|
215
|
+
* `reviewed.headSha` anterior, o `HEAD` atual o sucede (sem rebase) e não veio
|
|
216
|
+
* `--full`/`--clean`. Reconstrói só o delta `prevSha..headSha` e carrega os
|
|
217
|
+
* findings de arquivos inalterados do estado anterior.
|
|
218
|
+
*/
|
|
219
|
+
function resolveReviewScope(
|
|
220
|
+
opts: ReviewOptions,
|
|
221
|
+
previous: FindingsStateFile | null,
|
|
222
|
+
headSha: string,
|
|
223
|
+
config: HoundsmanConfig,
|
|
224
|
+
): { classification: RunClassification; diff: DiffSource; reviewedFrom?: string } {
|
|
225
|
+
const prevSha = previous?.reviewed?.headSha;
|
|
226
|
+
if (
|
|
227
|
+
opts.full ||
|
|
228
|
+
opts.clean ||
|
|
229
|
+
!prevSha ||
|
|
230
|
+
prevSha === headSha ||
|
|
231
|
+
!isAncestor(prevSha, headSha)
|
|
232
|
+
) {
|
|
233
|
+
const diff = collectDiff(opts.base, opts.head);
|
|
234
|
+
return {
|
|
235
|
+
diff,
|
|
236
|
+
classification: {
|
|
237
|
+
...classifyRange(opts.base, opts.head, config, diff),
|
|
238
|
+
headSha,
|
|
239
|
+
mode: "full",
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const range = `${prevSha}..${headSha}`;
|
|
245
|
+
const diff = collectDiffSince(prevSha, headSha);
|
|
246
|
+
const entries = diffEntries(range);
|
|
247
|
+
const changedPaths = new Set(entries.map((entry) => entry.path));
|
|
248
|
+
const classification: RunClassification = {
|
|
249
|
+
...classifyEntries(entries, config, churnToRecord(diff.churn)),
|
|
250
|
+
base: prevSha,
|
|
251
|
+
head: opts.head,
|
|
252
|
+
headSha,
|
|
253
|
+
mode: "incremental",
|
|
254
|
+
reviewedFrom: prevSha,
|
|
255
|
+
carryover: buildCarryover(previous, changedPaths),
|
|
256
|
+
};
|
|
257
|
+
return { classification, diff, reviewedFrom: prevSha };
|
|
258
|
+
}
|
|
259
|
+
|
|
99
260
|
// ---------------------------------------------------------------------------
|
|
100
261
|
// Findings
|
|
101
262
|
// ---------------------------------------------------------------------------
|
|
@@ -144,27 +305,47 @@ function writeManifest(
|
|
|
144
305
|
outDir: string,
|
|
145
306
|
opts: ReviewOptions,
|
|
146
307
|
branch: string,
|
|
147
|
-
classification:
|
|
308
|
+
classification: RunClassification,
|
|
148
309
|
activeIds: string[],
|
|
149
310
|
pending: string[],
|
|
150
311
|
jev: JevCandidatesFile,
|
|
151
312
|
config: HoundsmanConfig,
|
|
152
313
|
): void {
|
|
314
|
+
const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
|
|
315
|
+
let filesOverBudget = 0;
|
|
316
|
+
const watchdogs = activeIds.map((id) => {
|
|
317
|
+
const selection = budgetFor(classification, id, maxFilesPerBrief);
|
|
318
|
+
filesOverBudget += selection.overflow.length;
|
|
319
|
+
return {
|
|
320
|
+
id,
|
|
321
|
+
name: findWatchdog(config, id)?.name ?? id,
|
|
322
|
+
files: selection.selected,
|
|
323
|
+
overflow: selection.overflow.length,
|
|
324
|
+
brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
|
|
325
|
+
findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
|
|
326
|
+
status: pending.includes(id) ? "pending" : "done",
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
|
|
153
330
|
const manifest = {
|
|
154
331
|
branch,
|
|
155
332
|
slug: sanitizeBranch(branch),
|
|
156
333
|
base: opts.base,
|
|
157
334
|
head: opts.head,
|
|
335
|
+
headSha: classification.headSha,
|
|
158
336
|
generatedAt: new Date().toISOString(),
|
|
159
337
|
outputFormat: OUTPUT_FORMAT_PATH,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
338
|
+
budget: { maxFilesPerBrief, filesOverBudget },
|
|
339
|
+
churn: churnTotals(classification),
|
|
340
|
+
incremental:
|
|
341
|
+
classification.mode === "incremental"
|
|
342
|
+
? {
|
|
343
|
+
from: classification.reviewedFrom,
|
|
344
|
+
changedFiles: Object.keys(classification.files).length,
|
|
345
|
+
carried: classification.carryover?.length ?? 0,
|
|
346
|
+
}
|
|
347
|
+
: undefined,
|
|
348
|
+
watchdogs,
|
|
168
349
|
context: contextManifest(opts, branch),
|
|
169
350
|
jev: jev.available
|
|
170
351
|
? {
|
|
@@ -186,6 +367,37 @@ function writeManifest(
|
|
|
186
367
|
);
|
|
187
368
|
}
|
|
188
369
|
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Orçamento (triage de PRs grandes)
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/** Aplica o orçamento aos arquivos de um watchdog. */
|
|
375
|
+
function budgetFor(
|
|
376
|
+
classification: ClassificationResult,
|
|
377
|
+
id: string,
|
|
378
|
+
maxFiles: number,
|
|
379
|
+
): BudgetSelection {
|
|
380
|
+
return selectByBudget(
|
|
381
|
+
classification.watchdogs[id] ?? [],
|
|
382
|
+
classification.churn,
|
|
383
|
+
maxFiles,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Totais de churn do diff (métricas do manifesto). */
|
|
388
|
+
function churnTotals(classification: ClassificationResult): {
|
|
389
|
+
additions: number;
|
|
390
|
+
deletions: number;
|
|
391
|
+
} {
|
|
392
|
+
let additions = 0;
|
|
393
|
+
let deletions = 0;
|
|
394
|
+
for (const entry of Object.values(classification.churn ?? {})) {
|
|
395
|
+
additions += entry.additions;
|
|
396
|
+
deletions += entry.deletions;
|
|
397
|
+
}
|
|
398
|
+
return { additions, deletions };
|
|
399
|
+
}
|
|
400
|
+
|
|
189
401
|
// ---------------------------------------------------------------------------
|
|
190
402
|
// Briefs
|
|
191
403
|
// ---------------------------------------------------------------------------
|
|
@@ -200,14 +412,17 @@ function writeBriefs(
|
|
|
200
412
|
config: HoundsmanConfig,
|
|
201
413
|
): void {
|
|
202
414
|
const docs = discoverDocs(Object.keys(classification.files), config);
|
|
415
|
+
const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
|
|
203
416
|
for (const id of activeIds) {
|
|
204
417
|
const def = findWatchdog(config, id);
|
|
205
418
|
if (!def) continue;
|
|
206
|
-
const
|
|
419
|
+
const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
|
|
207
420
|
const brief = renderBrief(def, {
|
|
208
421
|
watchdogId: id,
|
|
209
|
-
files,
|
|
422
|
+
files: selected,
|
|
210
423
|
statuses: classification.files,
|
|
424
|
+
churn: classification.churn,
|
|
425
|
+
overflow,
|
|
211
426
|
deleted: classification.deleted,
|
|
212
427
|
branch,
|
|
213
428
|
outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
|
|
@@ -233,6 +448,7 @@ async function resolveCandidates(
|
|
|
233
448
|
classification: ClassificationResult,
|
|
234
449
|
dirs: Paths,
|
|
235
450
|
config: HoundsmanConfig,
|
|
451
|
+
diff: DiffSource,
|
|
236
452
|
): Promise<JevCandidatesFile> {
|
|
237
453
|
const store = new JevCandidatesStore(
|
|
238
454
|
join(dirs.outDir, "candidates.json"),
|
|
@@ -257,7 +473,10 @@ async function resolveCandidates(
|
|
|
257
473
|
client,
|
|
258
474
|
config.watchdogs as Record<string, WatchdogMeta>,
|
|
259
475
|
);
|
|
260
|
-
const result = await detector.detect(classification, opts.base, opts.head
|
|
476
|
+
const result = await detector.detect(classification, opts.base, opts.head, {
|
|
477
|
+
diff,
|
|
478
|
+
concurrency: resolveJevConcurrency(config),
|
|
479
|
+
});
|
|
261
480
|
return save({
|
|
262
481
|
available: true,
|
|
263
482
|
model: result.model,
|
|
@@ -301,6 +520,22 @@ function printDiscovery(
|
|
|
301
520
|
}
|
|
302
521
|
}
|
|
303
522
|
|
|
523
|
+
/** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
|
|
524
|
+
function printBudget(
|
|
525
|
+
classification: ClassificationResult,
|
|
526
|
+
config: HoundsmanConfig,
|
|
527
|
+
): void {
|
|
528
|
+
const maxFiles = resolveMaxFilesPerBrief(config);
|
|
529
|
+
let overflow = 0;
|
|
530
|
+
for (const id of Object.keys(classification.watchdogs)) {
|
|
531
|
+
overflow += budgetFor(classification, id, maxFiles).overflow.length;
|
|
532
|
+
}
|
|
533
|
+
if (overflow === 0) return;
|
|
534
|
+
console.log(
|
|
535
|
+
`Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
304
539
|
/** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
|
|
305
540
|
export async function prepare(
|
|
306
541
|
opts: ReviewOptions,
|
|
@@ -310,23 +545,40 @@ export async function prepare(
|
|
|
310
545
|
): Promise<void> {
|
|
311
546
|
prepareDirs(dirs, opts.clean);
|
|
312
547
|
|
|
313
|
-
|
|
548
|
+
// Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
|
|
549
|
+
writeRunStatus(dirs.outDir, { branch, status: "reviewing", resetStartedAt: true });
|
|
550
|
+
|
|
551
|
+
// Marca o commit revisado e decide entre review completo e incremental.
|
|
552
|
+
const headSha = resolveSha(opts.head);
|
|
553
|
+
const previous = FindingsStateStore.at(opts.dir, branch).read();
|
|
554
|
+
const { classification, diff } = resolveReviewScope(opts, previous, headSha, config);
|
|
314
555
|
writeFileSync(
|
|
315
556
|
join(dirs.outDir, "classification.json"),
|
|
316
557
|
`${JSON.stringify(classification, null, 2)}\n`,
|
|
317
558
|
);
|
|
318
559
|
|
|
319
|
-
const jev = await resolveCandidates(opts, classification, dirs, config);
|
|
560
|
+
const jev = await resolveCandidates(opts, classification, dirs, config, diff);
|
|
320
561
|
|
|
321
562
|
const activeIds = Object.keys(classification.watchdogs);
|
|
322
563
|
if (Object.keys(classification.files).length === 0) {
|
|
323
|
-
|
|
564
|
+
writeRunStatus(dirs.outDir, { branch, status: "review_done" });
|
|
565
|
+
console.log(
|
|
566
|
+
classification.mode === "incremental"
|
|
567
|
+
? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
|
|
568
|
+
: `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
|
|
569
|
+
);
|
|
324
570
|
return;
|
|
325
571
|
}
|
|
326
572
|
if (activeIds.length === 0) {
|
|
573
|
+
writeRunStatus(dirs.outDir, { branch, status: "review_done" });
|
|
327
574
|
console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
|
|
328
575
|
return;
|
|
329
576
|
}
|
|
577
|
+
if (classification.mode === "incremental") {
|
|
578
|
+
console.log(
|
|
579
|
+
`Modo incremental: ${Object.keys(classification.files).length} arquivo(s) alterado(s) desde ${shortSha(classification.reviewedFrom ?? "")}; ${classification.carryover?.length ?? 0} finding(s) carregado(s) do review anterior.`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
330
582
|
|
|
331
583
|
const context = readContextSummary(contextPaths(opts.dir, branch).summary);
|
|
332
584
|
|
|
@@ -342,6 +594,7 @@ export async function prepare(
|
|
|
342
594
|
config,
|
|
343
595
|
);
|
|
344
596
|
printDiscovery(activeIds, classification, dirs.briefsDir);
|
|
597
|
+
printBudget(classification, config);
|
|
345
598
|
printJevSummary(jev);
|
|
346
599
|
|
|
347
600
|
if (context) {
|
|
@@ -363,13 +616,57 @@ export async function prepare(
|
|
|
363
616
|
// ---------------------------------------------------------------------------
|
|
364
617
|
|
|
365
618
|
/** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
|
|
366
|
-
function readClassification(classificationPath: string):
|
|
619
|
+
function readClassification(classificationPath: string): RunClassification {
|
|
367
620
|
if (!existsSync(classificationPath)) {
|
|
368
621
|
throw new Error(
|
|
369
622
|
`Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
|
|
370
623
|
);
|
|
371
624
|
}
|
|
372
|
-
|
|
625
|
+
const parsed = JSON.parse(
|
|
626
|
+
readFileSync(classificationPath, "utf8"),
|
|
627
|
+
) as RunClassification;
|
|
628
|
+
return parsed;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
|
|
633
|
+
* Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
|
|
634
|
+
*/
|
|
635
|
+
function writeDomainReports(
|
|
636
|
+
outDir: string,
|
|
637
|
+
branch: string,
|
|
638
|
+
reports: WatchdogReport[],
|
|
639
|
+
classification: ClassificationResult,
|
|
640
|
+
context: ContextSummary | null,
|
|
641
|
+
nameOf: (id: string) => string,
|
|
642
|
+
): DomainReport[] {
|
|
643
|
+
const domains: DomainReport[] = [];
|
|
644
|
+
for (const report of reports) {
|
|
645
|
+
const findings = dedupeFindings([report]);
|
|
646
|
+
if (findings.length === 0) continue;
|
|
647
|
+
const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
|
|
648
|
+
const expected = classification.watchdogs[report.watchdogId] ?? [];
|
|
649
|
+
writeFileSync(
|
|
650
|
+
join(outDir, file),
|
|
651
|
+
`${renderDomainReport(
|
|
652
|
+
branch,
|
|
653
|
+
report,
|
|
654
|
+
findings,
|
|
655
|
+
expected.length,
|
|
656
|
+
context,
|
|
657
|
+
nameOf,
|
|
658
|
+
)}\n`,
|
|
659
|
+
);
|
|
660
|
+
domains.push({
|
|
661
|
+
id: report.watchdogId,
|
|
662
|
+
name: nameOf(report.watchdogId),
|
|
663
|
+
file,
|
|
664
|
+
findings: findings.length,
|
|
665
|
+
reviewedFiles: report.reviewedFiles.length,
|
|
666
|
+
expectedFiles: expected.length,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return domains;
|
|
373
670
|
}
|
|
374
671
|
|
|
375
672
|
export type FinalizeResult = {
|
|
@@ -415,8 +712,49 @@ export function finalize(
|
|
|
415
712
|
);
|
|
416
713
|
}
|
|
417
714
|
|
|
418
|
-
const
|
|
715
|
+
const now = new Date().toISOString();
|
|
716
|
+
// Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
|
|
717
|
+
const merged = mergeCarryover(
|
|
718
|
+
dedupeFindings(reports),
|
|
719
|
+
classification.carryover ?? [],
|
|
720
|
+
);
|
|
721
|
+
if (classification.mode === "incremental") {
|
|
722
|
+
console.log(
|
|
723
|
+
`Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Estado de triagem: preserva o que já existia (casando por fingerprint) e
|
|
728
|
+
// anexa o `id` de cada finding aos itens do relatório. A linha é cache —
|
|
729
|
+
// recalculada por `houndsman findings sync` após edições. O commit revisado
|
|
730
|
+
// (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
|
|
731
|
+
const store = FindingsStateStore.at(opts.dir, branch);
|
|
732
|
+
const state = buildStateFromFindings(merged, {
|
|
733
|
+
branch,
|
|
734
|
+
readFile: fileReader(process.cwd()),
|
|
735
|
+
previous: store.read(),
|
|
736
|
+
now,
|
|
737
|
+
headSha: classification.headSha,
|
|
738
|
+
reviewed: classification.headSha
|
|
739
|
+
? { headSha: classification.headSha, at: now }
|
|
740
|
+
: undefined,
|
|
741
|
+
});
|
|
742
|
+
store.write(state);
|
|
743
|
+
console.log(
|
|
744
|
+
`Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
|
|
745
|
+
);
|
|
746
|
+
writeRunStatus(dirs.outDir, { branch, status: "review_done" });
|
|
747
|
+
|
|
419
748
|
const context = readContextSummary(contextPaths(opts.dir, branch).summary);
|
|
749
|
+
const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
|
|
750
|
+
const domains = writeDomainReports(
|
|
751
|
+
dirs.outDir,
|
|
752
|
+
branch,
|
|
753
|
+
reports,
|
|
754
|
+
classification,
|
|
755
|
+
context,
|
|
756
|
+
nameOf,
|
|
757
|
+
);
|
|
420
758
|
const markdown = renderReport(
|
|
421
759
|
branch,
|
|
422
760
|
reports,
|
|
@@ -424,10 +762,11 @@ export function finalize(
|
|
|
424
762
|
{ classified: classification.watchdogs },
|
|
425
763
|
context,
|
|
426
764
|
jev.available ? jev.candidates : undefined,
|
|
427
|
-
|
|
765
|
+
nameOf,
|
|
766
|
+
domains,
|
|
428
767
|
);
|
|
429
768
|
const reportPath = join(dirs.outDir, "review.md");
|
|
430
|
-
writeFileSync(reportPath, `${markdown}\n`);
|
|
769
|
+
writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
|
|
431
770
|
|
|
432
771
|
const blockers = merged.filter((f) => f.severity === "blocking").length;
|
|
433
772
|
const attention = merged.filter((f) => f.severity === "attention").length;
|
package/src/core/paths.ts
CHANGED
|
@@ -28,3 +28,12 @@ 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";
|
|
34
|
+
|
|
35
|
+
/** Status da run (revisão/correção), dentro da pasta da run (`<slug>/run.json`). */
|
|
36
|
+
export const RUN_STATUS_FILE = "run.json";
|
|
37
|
+
|
|
38
|
+
/** Relatório da verificação de findings, dentro da pasta da run (`<slug>/verify.md`). */
|
|
39
|
+
export const VERIFY_FILE = "verify.md";
|
|
@@ -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 };
|