@payconductor/houndsman 0.1.1 → 0.1.4
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 +32 -14
- package/package.json +1 -1
- package/src/cli/index.ts +22 -11
- package/src/core/assets.ts +3 -0
- package/src/core/brief.ts +2 -3
- package/src/core/commands/findings.ts +232 -4
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +13 -44
- package/src/core/diff-source.ts +4 -4
- package/src/core/findings-state.ts +20 -2
- 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-detector.ts +2 -2
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +202 -15
- package/src/core/paths.ts +6 -0
- package/src/core/registry-check.ts +53 -0
- package/src/core/report-builder.ts +5 -0
- package/src/core/run-status.ts +123 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +1 -1
- package/src/server/artifacts.ts +1 -0
- package/src/server/client/RunList.tsx +22 -13
- package/src/server/client/constants.ts +32 -1
- package/src/server/client/types.ts +4 -0
- package/src/server/index.ts +12 -1
- package/src/skill/AUDIT-PROTOCOL.md +7 -2
- package/src/skill/README.md +32 -6
- package/src/skill/SETUP.md +11 -2
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +42 -68
- package/src/skill/{houndsman-update.md → train.md} +38 -17
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-commands.test.ts +252 -2
- package/src/tests/findings-state.test.ts +52 -1
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +36 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +30 -0
- package/src/tests/verify.test.ts +170 -0
package/src/core/orchestrator.ts
CHANGED
|
@@ -22,15 +22,22 @@ import {
|
|
|
22
22
|
resolveJevConcurrency,
|
|
23
23
|
resolveMaxFilesPerBrief,
|
|
24
24
|
} from "./config";
|
|
25
|
-
import { collectDiff, type DiffSource } from "./diff-source";
|
|
25
|
+
import { churnToRecord, collectDiff, type DiffSource } from "./diff-source";
|
|
26
26
|
import { discoverDocs } from "./doc-discoverer";
|
|
27
27
|
import {
|
|
28
28
|
type BudgetSelection,
|
|
29
29
|
type ClassificationResult,
|
|
30
|
+
classifyEntries,
|
|
30
31
|
classifyRange,
|
|
31
32
|
selectByBudget,
|
|
32
33
|
} from "./file-classifier";
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
buildStateFromFindings,
|
|
36
|
+
type FindingsStateFile,
|
|
37
|
+
FindingsStateStore,
|
|
38
|
+
fileReader,
|
|
39
|
+
} from "./findings-state";
|
|
40
|
+
import { collectDiffSince, diffEntries, isAncestor, resolveSha } from "./git-revision";
|
|
34
41
|
import {
|
|
35
42
|
type JevCandidate,
|
|
36
43
|
type JevCandidatesFile,
|
|
@@ -44,13 +51,15 @@ import {
|
|
|
44
51
|
type ContextSummary,
|
|
45
52
|
type DomainReport,
|
|
46
53
|
dedupeFindings,
|
|
54
|
+
type MergedFinding,
|
|
47
55
|
parseWatchdogReport,
|
|
48
56
|
renderDomainReport,
|
|
49
57
|
renderReport,
|
|
50
58
|
sanitizeBranch,
|
|
51
59
|
type WatchdogReport,
|
|
52
60
|
} from "./report-builder";
|
|
53
|
-
import
|
|
61
|
+
import { writeRunStatus } from "./run-status";
|
|
62
|
+
import { severityRank, type WatchdogMeta } from "./types";
|
|
54
63
|
import { findWatchdog } from "./watchdog-registry";
|
|
55
64
|
|
|
56
65
|
export type ReviewOptions = {
|
|
@@ -62,6 +71,8 @@ export type ReviewOptions = {
|
|
|
62
71
|
failOnBlocking: boolean;
|
|
63
72
|
clean: boolean;
|
|
64
73
|
noJev: boolean;
|
|
74
|
+
/** Força review completo (ignora o modo incremental). */
|
|
75
|
+
full: boolean;
|
|
65
76
|
};
|
|
66
77
|
|
|
67
78
|
/** Valores default das opções (o CLI sobrepõe com as flags). */
|
|
@@ -74,6 +85,7 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
|
|
|
74
85
|
failOnBlocking: false,
|
|
75
86
|
clean: false,
|
|
76
87
|
noJev: false,
|
|
88
|
+
full: false,
|
|
77
89
|
};
|
|
78
90
|
}
|
|
79
91
|
|
|
@@ -109,6 +121,142 @@ function prepareDirs(dirs: Paths, clean: boolean): void {
|
|
|
109
121
|
}
|
|
110
122
|
}
|
|
111
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
|
+
|
|
112
260
|
// ---------------------------------------------------------------------------
|
|
113
261
|
// Findings
|
|
114
262
|
// ---------------------------------------------------------------------------
|
|
@@ -157,7 +305,7 @@ function writeManifest(
|
|
|
157
305
|
outDir: string,
|
|
158
306
|
opts: ReviewOptions,
|
|
159
307
|
branch: string,
|
|
160
|
-
classification:
|
|
308
|
+
classification: RunClassification,
|
|
161
309
|
activeIds: string[],
|
|
162
310
|
pending: string[],
|
|
163
311
|
jev: JevCandidatesFile,
|
|
@@ -184,10 +332,19 @@ function writeManifest(
|
|
|
184
332
|
slug: sanitizeBranch(branch),
|
|
185
333
|
base: opts.base,
|
|
186
334
|
head: opts.head,
|
|
335
|
+
headSha: classification.headSha,
|
|
187
336
|
generatedAt: new Date().toISOString(),
|
|
188
337
|
outputFormat: OUTPUT_FORMAT_PATH,
|
|
189
338
|
budget: { maxFilesPerBrief, filesOverBudget },
|
|
190
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,
|
|
191
348
|
watchdogs,
|
|
192
349
|
context: contextManifest(opts, branch),
|
|
193
350
|
jev: jev.available
|
|
@@ -388,9 +545,13 @@ export async function prepare(
|
|
|
388
545
|
): Promise<void> {
|
|
389
546
|
prepareDirs(dirs, opts.clean);
|
|
390
547
|
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
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);
|
|
394
555
|
writeFileSync(
|
|
395
556
|
join(dirs.outDir, "classification.json"),
|
|
396
557
|
`${JSON.stringify(classification, null, 2)}\n`,
|
|
@@ -400,13 +561,24 @@ export async function prepare(
|
|
|
400
561
|
|
|
401
562
|
const activeIds = Object.keys(classification.watchdogs);
|
|
402
563
|
if (Object.keys(classification.files).length === 0) {
|
|
403
|
-
|
|
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
|
+
);
|
|
404
570
|
return;
|
|
405
571
|
}
|
|
406
572
|
if (activeIds.length === 0) {
|
|
573
|
+
writeRunStatus(dirs.outDir, { branch, status: "review_done" });
|
|
407
574
|
console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
|
|
408
575
|
return;
|
|
409
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
|
+
}
|
|
410
582
|
|
|
411
583
|
const context = readContextSummary(contextPaths(opts.dir, branch).summary);
|
|
412
584
|
|
|
@@ -444,7 +616,7 @@ export async function prepare(
|
|
|
444
616
|
// ---------------------------------------------------------------------------
|
|
445
617
|
|
|
446
618
|
/** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
|
|
447
|
-
function readClassification(classificationPath: string):
|
|
619
|
+
function readClassification(classificationPath: string): RunClassification {
|
|
448
620
|
if (!existsSync(classificationPath)) {
|
|
449
621
|
throw new Error(
|
|
450
622
|
`Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
|
|
@@ -452,9 +624,7 @@ function readClassification(classificationPath: string): ClassificationResult {
|
|
|
452
624
|
}
|
|
453
625
|
const parsed = JSON.parse(
|
|
454
626
|
readFileSync(classificationPath, "utf8"),
|
|
455
|
-
) as
|
|
456
|
-
// Compatível com classification.json gerado antes do churn existir.
|
|
457
|
-
parsed.churn ??= {};
|
|
627
|
+
) as RunClassification;
|
|
458
628
|
return parsed;
|
|
459
629
|
}
|
|
460
630
|
|
|
@@ -542,21 +712,38 @@ export function finalize(
|
|
|
542
712
|
);
|
|
543
713
|
}
|
|
544
714
|
|
|
545
|
-
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
|
+
}
|
|
546
726
|
|
|
547
727
|
// Estado de triagem: preserva o que já existia (casando por fingerprint) e
|
|
548
728
|
// anexa o `id` de cada finding aos itens do relatório. A linha é cache —
|
|
549
|
-
// recalculada por `houndsman findings sync` após edições.
|
|
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.
|
|
550
731
|
const store = FindingsStateStore.at(opts.dir, branch);
|
|
551
732
|
const state = buildStateFromFindings(merged, {
|
|
552
733
|
branch,
|
|
553
734
|
readFile: fileReader(process.cwd()),
|
|
554
735
|
previous: store.read(),
|
|
736
|
+
now,
|
|
737
|
+
headSha: classification.headSha,
|
|
738
|
+
reviewed: classification.headSha
|
|
739
|
+
? { headSha: classification.headSha, at: now }
|
|
740
|
+
: undefined,
|
|
555
741
|
});
|
|
556
742
|
store.write(state);
|
|
557
743
|
console.log(
|
|
558
744
|
`Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
|
|
559
745
|
);
|
|
746
|
+
writeRunStatus(dirs.outDir, { branch, status: "review_done" });
|
|
560
747
|
|
|
561
748
|
const context = readContextSummary(contextPaths(opts.dir, branch).summary);
|
|
562
749
|
const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
|
|
@@ -579,7 +766,7 @@ export function finalize(
|
|
|
579
766
|
domains,
|
|
580
767
|
);
|
|
581
768
|
const reportPath = join(dirs.outDir, "review.md");
|
|
582
|
-
writeFileSync(reportPath, `${markdown}\n`);
|
|
769
|
+
writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
|
|
583
770
|
|
|
584
771
|
const blockers = merged.filter((f) => f.severity === "blocking").length;
|
|
585
772
|
const attention = merged.filter((f) => f.severity === "attention").length;
|
package/src/core/paths.ts
CHANGED
|
@@ -31,3 +31,9 @@ export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;
|
|
|
31
31
|
|
|
32
32
|
/** Estado de triagem dos findings, dentro da pasta da run (`<slug>/state.json`). */
|
|
33
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,57 @@ function checkRulePolarity(config: HoundsmanConfig, warnings: string[]): void {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Normaliza o texto da regra para detectar duplicatas (case/espaços). */
|
|
131
|
+
function normalizeRuleText(text: string): string {
|
|
132
|
+
return text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Aviso para regra repetida (texto normalizado) no mesmo watchdog — a regra deve
|
|
137
|
+
* existir uma única vez em `rules` (§ Convenções de escrita, item 10).
|
|
138
|
+
*/
|
|
139
|
+
function checkRuleDuplication(config: HoundsmanConfig, warnings: string[]): void {
|
|
140
|
+
for (const id of config.watchdogIds) {
|
|
141
|
+
const def = config.watchdogs[id];
|
|
142
|
+
if (!def) continue;
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
def.rules.forEach((rule, index) => {
|
|
145
|
+
const key = normalizeRuleText(rule.text);
|
|
146
|
+
if (!key) return;
|
|
147
|
+
if (seen.has(key)) {
|
|
148
|
+
warnings.push(`Regra duplicada em ${id} (rule-${index}): ${rule.text}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
seen.add(key);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Campos de metadados que NÃO devem conter texto de regra. */
|
|
157
|
+
const RULE_BEARING_FIELDS = ["reviewFocus", "contextFiles", "exclude"] as const;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Aviso quando `reviewFocus`/`contextFiles`/`exclude` recebem texto de regra
|
|
161
|
+
* (prefixo de polaridade) — a regra vive só em `rules`; duplicá-la nesses campos
|
|
162
|
+
* deixa o brief do subagente redundante.
|
|
163
|
+
*/
|
|
164
|
+
function checkMetadataPrecision(config: HoundsmanConfig, warnings: string[]): void {
|
|
165
|
+
for (const id of config.watchdogIds) {
|
|
166
|
+
const def = config.watchdogs[id];
|
|
167
|
+
if (!def) continue;
|
|
168
|
+
for (const field of RULE_BEARING_FIELDS) {
|
|
169
|
+
const items = def[field];
|
|
170
|
+
if (!items) continue;
|
|
171
|
+
items.forEach((item, index) => {
|
|
172
|
+
if (!POLARITY_PREFIX.test(item.trim())) return;
|
|
173
|
+
warnings.push(
|
|
174
|
+
`Texto de regra em ${id}.${field}[${index}] — a regra vive só em 'rules': ${item}`,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
130
181
|
/** Aviso para orçamento/concorrência inválidos — o motor cai no default. */
|
|
131
182
|
function checkBudgets(config: HoundsmanConfig, warnings: string[]): void {
|
|
132
183
|
const maxFiles = config.budgets?.maxFilesPerBrief;
|
|
@@ -153,6 +204,8 @@ export function checkRegistry(config: HoundsmanConfig): {
|
|
|
153
204
|
checkClassification(config, problems);
|
|
154
205
|
checkRendering(config, problems);
|
|
155
206
|
checkRulePolarity(config, warnings);
|
|
207
|
+
checkRuleDuplication(config, warnings);
|
|
208
|
+
checkMetadataPrecision(config, warnings);
|
|
156
209
|
checkBudgets(config, warnings);
|
|
157
210
|
const jevQuestions = checkJevSpecs(config, problems);
|
|
158
211
|
|
|
@@ -25,6 +25,11 @@ export type Finding = {
|
|
|
25
25
|
message: string;
|
|
26
26
|
action: string;
|
|
27
27
|
evidence?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Commit em que o achado foi observado (interno). Preenchido nos carry-over
|
|
30
|
+
* do review incremental — preserva a referência para a verificação (verify).
|
|
31
|
+
*/
|
|
32
|
+
foundAtSha?: string;
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
export type WatchdogReport = {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status do run (biblioteca) — persistido em `.houndsman/.runs/<slug>/run.json`.
|
|
3
|
+
*
|
|
4
|
+
* Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
|
|
5
|
+
* (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
|
|
6
|
+
*
|
|
7
|
+
* Toda ação que muda o estado faz um **ping** no `updatedAt`, mantendo o arquivo
|
|
8
|
+
* "quente". Timeout é **display-only** (a UI compara `updatedAt` com o agora) —
|
|
9
|
+
* não é um status persistido.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { FindingsStateFile } from "./findings-state";
|
|
15
|
+
import { RUN_STATUS_FILE } from "./paths";
|
|
16
|
+
import { sanitizeBranch } from "./report-builder";
|
|
17
|
+
|
|
18
|
+
/** Estados de execução de uma run (persistidos). */
|
|
19
|
+
export type RunStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
|
|
20
|
+
|
|
21
|
+
/** Valores canônicos de status (ordem de exibição/lifecycle). */
|
|
22
|
+
export const RUN_STATUSES: readonly RunStatus[] = [
|
|
23
|
+
"reviewing",
|
|
24
|
+
"review_done",
|
|
25
|
+
"fixing",
|
|
26
|
+
"fix_done",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const RUN_STATUS_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** Conteúdo do `run.json`. */
|
|
32
|
+
export type RunStatusFile = {
|
|
33
|
+
version: 1;
|
|
34
|
+
slug: string;
|
|
35
|
+
branch: string;
|
|
36
|
+
status: RunStatus;
|
|
37
|
+
/** Início do ciclo de revisão atual (reiniciado a cada `prepare`). */
|
|
38
|
+
startedAt: string;
|
|
39
|
+
/** Última ação importante (ping). */
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Caminho do `run.json` dentro da pasta da run. */
|
|
44
|
+
export function runStatusPath(runDir: string): string {
|
|
45
|
+
return join(runDir, RUN_STATUS_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lê o `run.json` da run (null se ausente/inválido). */
|
|
49
|
+
export function readRunStatus(runDir: string): RunStatusFile | null {
|
|
50
|
+
const path = runStatusPath(runDir);
|
|
51
|
+
if (!existsSync(path)) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(path, "utf8")) as RunStatusFile;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type WriteRunStatusOptions = {
|
|
60
|
+
branch: string;
|
|
61
|
+
status: RunStatus;
|
|
62
|
+
/** Reinicia o `startedAt` — usado no início de uma nova revisão. */
|
|
63
|
+
resetStartedAt?: boolean;
|
|
64
|
+
now?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Grava/atualiza o `run.json` (ping no `updatedAt`). */
|
|
68
|
+
export function writeRunStatus(
|
|
69
|
+
runDir: string,
|
|
70
|
+
options: WriteRunStatusOptions,
|
|
71
|
+
): RunStatusFile {
|
|
72
|
+
const now = options.now ?? new Date().toISOString();
|
|
73
|
+
const previous = readRunStatus(runDir);
|
|
74
|
+
const file: RunStatusFile = {
|
|
75
|
+
version: RUN_STATUS_VERSION,
|
|
76
|
+
slug: sanitizeBranch(options.branch),
|
|
77
|
+
branch: options.branch,
|
|
78
|
+
status: options.status,
|
|
79
|
+
startedAt: options.resetStartedAt ? now : (previous?.startedAt ?? now),
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
};
|
|
82
|
+
mkdirSync(runDir, { recursive: true });
|
|
83
|
+
writeFileSync(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
|
|
84
|
+
return file;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deriva o status após uma mudança nos findings (triagem/correção).
|
|
89
|
+
* `fixing` exige correção em andamento (`in_progress`) ou retomada de um fix;
|
|
90
|
+
* `fix_done` quando o fix começou e a fila (`execute` não `done`) esvaziou.
|
|
91
|
+
*/
|
|
92
|
+
export function statusAfterFindingsChange(
|
|
93
|
+
previous: RunStatus | null,
|
|
94
|
+
state: FindingsStateFile,
|
|
95
|
+
): RunStatus {
|
|
96
|
+
const findings = Object.values(state.findings);
|
|
97
|
+
const active = findings.filter((finding) => finding.present);
|
|
98
|
+
|
|
99
|
+
if (active.some((finding) => finding.execState === "in_progress")) return "fixing";
|
|
100
|
+
|
|
101
|
+
const queueRemaining = active.some(
|
|
102
|
+
(finding) => finding.decision === "execute" && finding.execState !== "done",
|
|
103
|
+
);
|
|
104
|
+
const anyDone = active.some(
|
|
105
|
+
(finding) => finding.decision === "execute" && finding.execState === "done",
|
|
106
|
+
);
|
|
107
|
+
const fixStarted = previous === "fixing" || previous === "fix_done" || anyDone;
|
|
108
|
+
if (fixStarted) return queueRemaining ? "fixing" : "fix_done";
|
|
109
|
+
|
|
110
|
+
return previous ?? "review_done";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Ping: deriva o status da mudança nos findings e grava o `run.json`. */
|
|
114
|
+
export function pingFindingsStatus(
|
|
115
|
+
runDir: string,
|
|
116
|
+
state: FindingsStateFile,
|
|
117
|
+
): RunStatusFile {
|
|
118
|
+
const previous = readRunStatus(runDir)?.status ?? null;
|
|
119
|
+
return writeRunStatus(runDir, {
|
|
120
|
+
branch: state.branch,
|
|
121
|
+
status: statusAfterFindingsChange(previous, state),
|
|
122
|
+
});
|
|
123
|
+
}
|