@payconductor/houndsman 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/arg-parser.ts +8 -0
- package/src/cli/commands/command.ts +23 -0
- package/src/cli/commands/context-command.ts +38 -0
- package/src/cli/commands/doctor-command.ts +55 -0
- package/src/cli/commands/findings-command.ts +28 -0
- package/src/cli/commands/help-command.ts +13 -0
- package/src/cli/commands/registry.ts +34 -0
- package/src/cli/commands/review-command.ts +22 -0
- package/src/cli/commands/server-command.ts +37 -0
- package/src/cli/commands/setup-command.ts +34 -0
- package/src/cli/commands/watchdog-command.ts +34 -0
- package/src/cli/help.ts +43 -0
- package/src/cli/index.ts +62 -260
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/findings.ts +163 -147
- package/src/core/commands/review.ts +18 -35
- package/src/core/commands/scaffold.ts +174 -64
- package/src/core/commands/setup.ts +16 -11
- package/src/core/commands/update.ts +7 -7
- package/src/core/commands/watchdog.ts +8 -80
- package/src/core/config.ts +9 -19
- package/src/core/defaults.ts +20 -0
- package/src/core/diff-source.ts +1 -13
- package/src/core/doc-discoverer.ts +25 -15
- package/src/core/domain/budget.ts +42 -0
- package/src/core/domain/classification.ts +94 -0
- package/src/core/domain/diff.ts +53 -0
- package/src/core/domain/watchdog.ts +96 -0
- package/src/core/engine/carryover.ts +67 -0
- package/src/core/engine/houndsman-engine.ts +51 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +51 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +189 -38
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +423 -0
- package/src/core/orchestrator.ts +116 -337
- package/src/core/paths.ts +9 -0
- package/src/core/pr-context.ts +28 -54
- package/src/core/project-config.ts +57 -0
- package/src/core/renderers/batch-presenter.ts +40 -0
- package/src/core/renderers/brief-renderer.ts +23 -0
- package/src/core/renderers/findings-presenter.ts +74 -0
- package/src/core/renderers/manifest-writer.ts +112 -0
- package/src/core/renderers/report-renderer.ts +66 -0
- package/src/core/renderers/verify-presenter.ts +50 -0
- package/src/core/renderers/watchdog-presenter.ts +100 -0
- package/src/core/report-builder.ts +12 -7
- package/src/core/run-layout.ts +108 -0
- package/src/core/run-status.ts +6 -11
- package/src/core/services/clock.ts +12 -0
- package/src/core/services/file-classifier.ts +34 -0
- package/src/core/services/fix-batch-builder.ts +13 -0
- package/src/core/services/gh-client.ts +54 -0
- package/src/core/services/git-repository.ts +123 -0
- package/src/core/services/project-files.ts +79 -0
- package/src/core/services/verifier.ts +58 -0
- package/src/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +31 -1
- package/src/server/client/App.tsx +174 -49
- package/src/server/client/ArtifactsPanel.tsx +2 -2
- package/src/server/client/AutoRefresh.tsx +38 -0
- package/src/server/client/Filters.tsx +72 -53
- package/src/server/client/FindingCard.tsx +212 -71
- package/src/server/client/FindingsList.tsx +8 -6
- package/src/server/client/RunList.tsx +30 -7
- package/src/server/client/SubmitModal.tsx +418 -0
- package/src/server/client/SummaryBar.tsx +65 -12
- package/src/server/client/constants.ts +9 -12
- package/src/server/client/format.ts +40 -0
- package/src/server/client/icons.tsx +143 -0
- package/src/server/client/types.ts +69 -0
- package/src/server/index.ts +316 -222
- package/src/server/run-repository.ts +291 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
- package/src/tests/baseline.test.ts +513 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/infrastructure.test.ts +210 -0
- package/src/tests/server.test.ts +117 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classificação (domínio) — arquivos alterados por domínio de review.
|
|
3
|
+
*
|
|
4
|
+
* Envolve o `ClassificationResult` produzido pelo classificador e concentra as
|
|
5
|
+
* consultas usadas pelo orquestrador (arquivos por watchdog, orçamento, churn).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClassificationResult } from "../file-classifier";
|
|
9
|
+
import type { ChurnMap } from "../types";
|
|
10
|
+
import { type BudgetSelection, selectByBudget } from "./budget";
|
|
11
|
+
|
|
12
|
+
/** Classificação de um diff por watchdog. */
|
|
13
|
+
export class Classification {
|
|
14
|
+
private readonly data: ClassificationResult;
|
|
15
|
+
|
|
16
|
+
constructor(data: ClassificationResult) {
|
|
17
|
+
this.data = data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static from(data: ClassificationResult): Classification {
|
|
21
|
+
return new Classification(data);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get watchdogs(): Record<string, string[]> {
|
|
25
|
+
return this.data.watchdogs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get files(): ClassificationResult["files"] {
|
|
29
|
+
return this.data.files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get churn(): ChurnMap {
|
|
33
|
+
return this.data.churn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get unclassified(): string[] {
|
|
37
|
+
return this.data.unclassified;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get excluded(): string[] {
|
|
41
|
+
return this.data.excluded;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get deleted(): string[] {
|
|
45
|
+
return this.data.deleted;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get stats(): Record<string, number> {
|
|
49
|
+
return this.data.stats;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get excludedCount(): number {
|
|
53
|
+
return this.data.excludedCount;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get base(): string | undefined {
|
|
57
|
+
return this.data.base;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get head(): string | undefined {
|
|
61
|
+
return this.data.head;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ids de watchdog ativados (com ao menos um arquivo). */
|
|
65
|
+
get activeIds(): string[] {
|
|
66
|
+
return Object.keys(this.data.watchdogs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Arquivos de um watchdog (vazio quando não ativado). */
|
|
70
|
+
watchdogsFor(id: string): string[] {
|
|
71
|
+
return this.data.watchdogs[id] ?? [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Aplica o orçamento aos arquivos de um watchdog. */
|
|
75
|
+
budgetFor(id: string, maxFiles: number): BudgetSelection {
|
|
76
|
+
return selectByBudget(this.watchdogsFor(id), this.data.churn, maxFiles);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Totais de churn do diff. */
|
|
80
|
+
get churnTotals(): { additions: number; deletions: number } {
|
|
81
|
+
let additions = 0;
|
|
82
|
+
let deletions = 0;
|
|
83
|
+
for (const entry of Object.values(this.data.churn ?? {})) {
|
|
84
|
+
additions += entry.additions;
|
|
85
|
+
deletions += entry.deletions;
|
|
86
|
+
}
|
|
87
|
+
return { additions, deletions };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Payload serializável (mesmo shape do `classification.json`). */
|
|
91
|
+
toJSON(): ClassificationResult {
|
|
92
|
+
return this.data;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff (domínio) — patch por arquivo + churn, com consultas coesas.
|
|
3
|
+
*
|
|
4
|
+
* Envolve o `DiffSource` coletado uma única vez pelo motor; não executa git.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DiffSource } from "../diff-source";
|
|
8
|
+
import type { FileChurn } from "../types";
|
|
9
|
+
|
|
10
|
+
/** Diff coletado de um intervalo de commits. */
|
|
11
|
+
export class Diff {
|
|
12
|
+
private readonly source: DiffSource;
|
|
13
|
+
|
|
14
|
+
constructor(source: DiffSource) {
|
|
15
|
+
this.source = source;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Diff vazio (sem patch nem churn). */
|
|
19
|
+
static empty(): Diff {
|
|
20
|
+
return new Diff({ byFile: new Map(), churn: new Map() });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Todos os caminhos presentes no patch ou no churn. */
|
|
24
|
+
get paths(): string[] {
|
|
25
|
+
return [...new Set([...this.source.byFile.keys(), ...this.source.churn.keys()])];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Patch (header + hunks) de um arquivo, se houver. */
|
|
29
|
+
patchFor(path: string): string | undefined {
|
|
30
|
+
return this.source.byFile.get(path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Churn de um arquivo; zero quando ausente. */
|
|
34
|
+
churnOf(path: string): FileChurn {
|
|
35
|
+
return this.source.churn.get(path) ?? { additions: 0, deletions: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Totais de churn do diff. */
|
|
39
|
+
get totals(): { additions: number; deletions: number } {
|
|
40
|
+
let additions = 0;
|
|
41
|
+
let deletions = 0;
|
|
42
|
+
for (const entry of this.source.churn.values()) {
|
|
43
|
+
additions += entry.additions;
|
|
44
|
+
deletions += entry.deletions;
|
|
45
|
+
}
|
|
46
|
+
return { additions, deletions };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `DiffSource` subjacente (para as funções puras que ainda o recebem). */
|
|
50
|
+
toSource(): DiffSource {
|
|
51
|
+
return this.source;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog (domínio) — envolve a definição declarativa do projeto.
|
|
3
|
+
*
|
|
4
|
+
* A configuração continua sendo um objeto plano (`WatchdogDefinition`); esta
|
|
5
|
+
* classe concentra o comportamento (casar arquivo, ordenar regras, contexto e
|
|
6
|
+
* render do brief) sem alterar o contrato do consumidor.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { renderBrief } from "../brief";
|
|
10
|
+
import {
|
|
11
|
+
type ClassifyRule,
|
|
12
|
+
type Rule,
|
|
13
|
+
severityRank,
|
|
14
|
+
type WatchdogDefinition,
|
|
15
|
+
type WatchdogRenderArgs,
|
|
16
|
+
} from "../types";
|
|
17
|
+
|
|
18
|
+
/** Tipo assumido quando `type` é omitido. */
|
|
19
|
+
const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
|
|
20
|
+
|
|
21
|
+
/** `true` se o arquivo casa a entrada de classificação. */
|
|
22
|
+
export function matchesClassifyRule(
|
|
23
|
+
filePath: string,
|
|
24
|
+
rule: ClassifyRule,
|
|
25
|
+
content: string | undefined,
|
|
26
|
+
): boolean {
|
|
27
|
+
if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
|
|
28
|
+
return content !== undefined && new RegExp(rule.value).test(content);
|
|
29
|
+
}
|
|
30
|
+
return new RegExp(rule.value).test(filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Contexto "essencial" do watchdog — SEM regras (docs/UI/inspeção). */
|
|
34
|
+
export type WatchdogContext = {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
description: string;
|
|
38
|
+
scope: string;
|
|
39
|
+
reviewFocus?: readonly string[];
|
|
40
|
+
exclude?: readonly string[];
|
|
41
|
+
contextFiles: string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Comportamento de um watchdog do projeto. */
|
|
45
|
+
export class Watchdog {
|
|
46
|
+
readonly definition: WatchdogDefinition;
|
|
47
|
+
|
|
48
|
+
constructor(definition: WatchdogDefinition) {
|
|
49
|
+
this.definition = definition;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get id(): string {
|
|
53
|
+
return this.definition.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get name(): string {
|
|
57
|
+
return this.definition.name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get description(): string {
|
|
61
|
+
return this.definition.description;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get scope(): string {
|
|
65
|
+
return this.definition.scope;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Regras de classificação declaradas (vazio = não casa nenhum arquivo). */
|
|
69
|
+
get classify(): readonly ClassifyRule[] {
|
|
70
|
+
return this.definition.classify ?? [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `true` se o arquivo é deste domínio. */
|
|
74
|
+
matches(filePath: string, content?: string): boolean {
|
|
75
|
+
return this.classify.some((rule) => matchesClassifyRule(filePath, rule, content));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Regras ordenadas por severidade (mais grave primeiro). */
|
|
79
|
+
get rules(): readonly Rule[] {
|
|
80
|
+
return [...this.definition.rules].sort(
|
|
81
|
+
(a, b) => severityRank(a.severity) - severityRank(b.severity),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Metadados sem as regras. */
|
|
86
|
+
context(): WatchdogContext {
|
|
87
|
+
const { id, name, description, scope, reviewFocus, exclude, contextFiles } =
|
|
88
|
+
this.definition;
|
|
89
|
+
return { id, name, description, scope, reviewFocus, exclude, contextFiles };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render do brief (hook custom ou default). */
|
|
93
|
+
renderBrief(args: WatchdogRenderArgs): string {
|
|
94
|
+
return renderBrief(this.definition, args);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carry-over do review incremental (engine) — findings de arquivos inalterados.
|
|
3
|
+
*
|
|
4
|
+
* Preserva o estado anterior sem re-revisar: o prepare carrega o carry-over e o
|
|
5
|
+
* finalize o mescla aos findings re-reportados.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FindingsStateFile } from "../findings-state";
|
|
9
|
+
import type { MergedFinding } from "../report-builder";
|
|
10
|
+
import { severityRank } from "../types";
|
|
11
|
+
|
|
12
|
+
/** Chave de dedupe de finding: `(file, line, rule)`. */
|
|
13
|
+
function findingKey(finding: Pick<MergedFinding, "file" | "line" | "rule">): string {
|
|
14
|
+
return `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Findings do review anterior em arquivos inalterados — carregados sem
|
|
19
|
+
* re-revisão (carry-over). Preserva o `foundAtSha` para o diff focado do verify.
|
|
20
|
+
*/
|
|
21
|
+
export function buildCarryover(
|
|
22
|
+
previous: FindingsStateFile | null,
|
|
23
|
+
changedPaths: Set<string>,
|
|
24
|
+
): MergedFinding[] {
|
|
25
|
+
if (!previous) return [];
|
|
26
|
+
return Object.values(previous.findings)
|
|
27
|
+
.filter((finding) => finding.present && !changedPaths.has(finding.file))
|
|
28
|
+
.map((finding) => ({
|
|
29
|
+
id: finding.id,
|
|
30
|
+
rule: finding.rule,
|
|
31
|
+
severity: finding.severity,
|
|
32
|
+
file: finding.file,
|
|
33
|
+
line: finding.line ?? undefined,
|
|
34
|
+
message: finding.message,
|
|
35
|
+
action: finding.action,
|
|
36
|
+
evidence: finding.evidence,
|
|
37
|
+
foundAtSha: finding.foundAtSha,
|
|
38
|
+
watchdogs: [...finding.watchdogIds],
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Une os findings re-reportados com os carry-over (preserva ids anteriores). */
|
|
43
|
+
export function mergeCarryover(
|
|
44
|
+
merged: MergedFinding[],
|
|
45
|
+
carryover: readonly MergedFinding[],
|
|
46
|
+
): MergedFinding[] {
|
|
47
|
+
const byKey = new Map(merged.map((finding) => [findingKey(finding), finding]));
|
|
48
|
+
for (const carried of carryover) {
|
|
49
|
+
const existing = byKey.get(findingKey(carried));
|
|
50
|
+
if (existing) {
|
|
51
|
+
for (const watchdog of carried.watchdogs) {
|
|
52
|
+
if (!existing.watchdogs.includes(watchdog))
|
|
53
|
+
existing.watchdogs.push(watchdog);
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
byKey.set(findingKey(carried), carried);
|
|
58
|
+
merged.push(carried);
|
|
59
|
+
}
|
|
60
|
+
merged.sort((a, b) => {
|
|
61
|
+
const bySeverity = severityRank(a.severity) - severityRank(b.severity);
|
|
62
|
+
if (bySeverity !== 0) return bySeverity;
|
|
63
|
+
if (a.file !== b.file) return a.file.localeCompare(b.file);
|
|
64
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
65
|
+
});
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine do houndsman (composition root).
|
|
3
|
+
*
|
|
4
|
+
* Injeta a configuração do projeto e os colaboradores compartilhados (git,
|
|
5
|
+
* classificador, verifier, lote). Comandos e pipelines pedem ao engine em vez
|
|
6
|
+
* de instanciar/importar tudo diretamente.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HoundsmanConfig } from "../config";
|
|
10
|
+
import type { ReviewOptions } from "../orchestrator";
|
|
11
|
+
import { ProjectConfig } from "../project-config";
|
|
12
|
+
import { FileClassifier } from "../services/file-classifier";
|
|
13
|
+
import { FixBatchBuilder } from "../services/fix-batch-builder";
|
|
14
|
+
import { GitRepository } from "../services/git-repository";
|
|
15
|
+
import { Verifier } from "../services/verifier";
|
|
16
|
+
import { ReviewSession } from "./review-session";
|
|
17
|
+
|
|
18
|
+
/** Colaboradores injetáveis (testes/CLI). */
|
|
19
|
+
export type EngineDeps = {
|
|
20
|
+
git?: GitRepository;
|
|
21
|
+
classifier?: FileClassifier;
|
|
22
|
+
verifier?: Verifier;
|
|
23
|
+
fixBatch?: FixBatchBuilder;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Composition root do motor. */
|
|
27
|
+
export class HoundsmanEngine {
|
|
28
|
+
readonly project: ProjectConfig;
|
|
29
|
+
readonly git: GitRepository;
|
|
30
|
+
readonly classifier: FileClassifier;
|
|
31
|
+
readonly verifier: Verifier;
|
|
32
|
+
readonly fixBatch: FixBatchBuilder;
|
|
33
|
+
|
|
34
|
+
constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
|
|
35
|
+
this.project = new ProjectConfig(raw);
|
|
36
|
+
this.git = deps.git ?? new GitRepository();
|
|
37
|
+
this.classifier = deps.classifier ?? new FileClassifier(raw);
|
|
38
|
+
this.verifier = deps.verifier ?? new Verifier();
|
|
39
|
+
this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Configuração crua (contrato do consumidor). */
|
|
43
|
+
get config(): HoundsmanConfig {
|
|
44
|
+
return this.project.raw;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Abre uma sessão de revisão (prepare/finalize). */
|
|
48
|
+
session(options: ReviewOptions): ReviewSession {
|
|
49
|
+
return ReviewSession.from(this.config, options);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escopo da revisão (engine) — completo vs incremental.
|
|
3
|
+
*
|
|
4
|
+
* Decide se reconstrói o diff inteiro ou só o delta `prevSha..headSha` e
|
|
5
|
+
* carrega os findings de arquivos inalterados (carry-over).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HoundsmanConfig } from "../config";
|
|
9
|
+
import { churnToRecord, collectDiff, type DiffSource } from "../diff-source";
|
|
10
|
+
import type { ClassificationResult } from "../file-classifier";
|
|
11
|
+
import type { FindingsStateFile } from "../findings-state";
|
|
12
|
+
import { collectDiffSince, diffEntries, isAncestor } from "../git-revision";
|
|
13
|
+
import type { MergedFinding } from "../report-builder";
|
|
14
|
+
import { FileClassifier } from "../services/file-classifier";
|
|
15
|
+
import { buildCarryover } from "./carryover";
|
|
16
|
+
|
|
17
|
+
/** Opções que afetam o escopo (subset do `ReviewOptions`). */
|
|
18
|
+
export type ScopeOptions = {
|
|
19
|
+
base: string;
|
|
20
|
+
head: string;
|
|
21
|
+
full: boolean;
|
|
22
|
+
clean: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Classificação enriquecida gravada no `classification.json`. */
|
|
26
|
+
export type RunClassification = ClassificationResult & {
|
|
27
|
+
/** Commit revisado (head do diff). */
|
|
28
|
+
headSha?: string;
|
|
29
|
+
/** Modo do prepare: completo ou incremental. */
|
|
30
|
+
mode: "full" | "incremental";
|
|
31
|
+
/** Commit da última revisão (só no incremental). */
|
|
32
|
+
reviewedFrom?: string;
|
|
33
|
+
/** Findings preservados do review anterior (arquivos inalterados). */
|
|
34
|
+
carryover?: MergedFinding[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Resultado da resolução de escopo. */
|
|
38
|
+
export type ReviewScope = {
|
|
39
|
+
classification: RunClassification;
|
|
40
|
+
diff: DiffSource;
|
|
41
|
+
reviewedFrom?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Decide entre review completo e incremental. */
|
|
45
|
+
export class ReviewScopeResolver {
|
|
46
|
+
private readonly options: ScopeOptions;
|
|
47
|
+
private readonly classifier: FileClassifier;
|
|
48
|
+
|
|
49
|
+
constructor(options: ScopeOptions, config: HoundsmanConfig) {
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.classifier = new FileClassifier(config);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
|
|
56
|
+
* sucede (sem rebase) e não veio `--full`/`--clean`.
|
|
57
|
+
*/
|
|
58
|
+
resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
|
|
59
|
+
const prevSha = previous?.reviewed?.headSha;
|
|
60
|
+
if (
|
|
61
|
+
this.options.full ||
|
|
62
|
+
this.options.clean ||
|
|
63
|
+
!prevSha ||
|
|
64
|
+
prevSha === headSha ||
|
|
65
|
+
!isAncestor(prevSha, headSha)
|
|
66
|
+
) {
|
|
67
|
+
const diff = collectDiff(this.options.base, this.options.head);
|
|
68
|
+
return {
|
|
69
|
+
diff,
|
|
70
|
+
classification: {
|
|
71
|
+
...this.classifier
|
|
72
|
+
.classifyRange(this.options.base, this.options.head, diff)
|
|
73
|
+
.toJSON(),
|
|
74
|
+
headSha,
|
|
75
|
+
mode: "full",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const range = `${prevSha}..${headSha}`;
|
|
81
|
+
const diff = collectDiffSince(prevSha, headSha);
|
|
82
|
+
const entries = diffEntries(range);
|
|
83
|
+
const changedPaths = new Set(entries.map((entry) => entry.path));
|
|
84
|
+
const classification: RunClassification = {
|
|
85
|
+
...this.classifier.classify(entries, churnToRecord(diff.churn)).toJSON(),
|
|
86
|
+
base: prevSha,
|
|
87
|
+
head: this.options.head,
|
|
88
|
+
headSha,
|
|
89
|
+
mode: "incremental",
|
|
90
|
+
reviewedFrom: prevSha,
|
|
91
|
+
carryover: buildCarryover(previous, changedPaths),
|
|
92
|
+
};
|
|
93
|
+
return { classification, diff, reviewedFrom: prevSha };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessão de uma run (engine) — amarra opções, branch e layout.
|
|
3
|
+
*
|
|
4
|
+
* É o ponto de entrada do `prepare`/`finalize` para o CLI e para o engine.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { HoundsmanConfig } from "../config";
|
|
8
|
+
import {
|
|
9
|
+
type FinalizeResult,
|
|
10
|
+
finalize,
|
|
11
|
+
type PrepareResult,
|
|
12
|
+
prepare,
|
|
13
|
+
type ReviewOptions,
|
|
14
|
+
resolveBranch,
|
|
15
|
+
} from "../orchestrator";
|
|
16
|
+
import { RunLayout } from "../run-layout";
|
|
17
|
+
|
|
18
|
+
/** Uma revisão em andamento (prepare ou finalize). */
|
|
19
|
+
export class ReviewSession {
|
|
20
|
+
readonly config: HoundsmanConfig;
|
|
21
|
+
readonly options: ReviewOptions;
|
|
22
|
+
readonly branch: string;
|
|
23
|
+
readonly layout: RunLayout;
|
|
24
|
+
|
|
25
|
+
constructor(config: HoundsmanConfig, options: ReviewOptions, branch: string) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.options = options;
|
|
28
|
+
this.branch = branch;
|
|
29
|
+
this.layout = new RunLayout(options.dir, branch);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve a branch (opção explícita ou a atual do git). */
|
|
33
|
+
static from(config: HoundsmanConfig, options: ReviewOptions): ReviewSession {
|
|
34
|
+
return new ReviewSession(config, options, resolveBranch(options));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Passo 1 — preparar. */
|
|
38
|
+
prepare(): Promise<PrepareResult> {
|
|
39
|
+
return prepare(this.options, this.branch, this.config, this.layout);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Passo 3 — finalizar. */
|
|
43
|
+
finalize(): FinalizeResult {
|
|
44
|
+
return finalize(this.options, this.branch, this.config, this.layout);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Executa o passo escolhido em `--finalize`. */
|
|
48
|
+
run(): Promise<PrepareResult | FinalizeResult> {
|
|
49
|
+
return this.options.finalize ? Promise.resolve(this.finalize()) : this.prepare();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import type { HoundsmanConfig } from "./config";
|
|
10
10
|
import { churnToRecord, collectChurn, type DiffSource } from "./diff-source";
|
|
11
|
-
import
|
|
11
|
+
import { matchesClassifyRule } from "./domain/watchdog";
|
|
12
|
+
import { runGit } from "./git-runner";
|
|
13
|
+
import type { ChurnMap, WatchdogDefinition } from "./types";
|
|
12
14
|
|
|
13
15
|
type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
|
|
14
16
|
|
|
@@ -42,9 +44,6 @@ export type ClassificationResult = {
|
|
|
42
44
|
// Matchers genéricos (reutilizados pelo projeto)
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
|
|
45
|
-
/** Tipo de classificação assumido quando `type` é omitido. */
|
|
46
|
-
const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
|
|
47
|
-
|
|
48
47
|
/** `true` se há entradas de conteúdo (as únicas que exigem ler o arquivo). */
|
|
49
48
|
function hasContentRules<TId extends string>(
|
|
50
49
|
watchdogs: Record<TId, WatchdogDefinition>,
|
|
@@ -64,18 +63,6 @@ function readFileContent(filePath: string): string | undefined {
|
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
/** `true` se o arquivo casa a entrada de classificação. */
|
|
68
|
-
function matchesClassifyRule(
|
|
69
|
-
filePath: string,
|
|
70
|
-
rule: ClassifyRule,
|
|
71
|
-
content: string | undefined,
|
|
72
|
-
): boolean {
|
|
73
|
-
if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
|
|
74
|
-
return content !== undefined && new RegExp(rule.value).test(content);
|
|
75
|
-
}
|
|
76
|
-
return new RegExp(rule.value).test(filePath);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
66
|
/** Watchdogs que declaram o arquivo (união dos `classify`). */
|
|
80
67
|
export function matchWatchdogs<TId extends string>(
|
|
81
68
|
filePath: string,
|
|
@@ -101,22 +88,6 @@ export function isExcludedPath(filePath: string, patterns: readonly RegExp[]): b
|
|
|
101
88
|
return patterns.some((pattern) => pattern.test(filePath));
|
|
102
89
|
}
|
|
103
90
|
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Execução do git diff
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
function runGitDiff(base: string, head: string): string {
|
|
109
|
-
const result = Bun.spawnSync(["git", "diff", `${base}...${head}`, "--name-status"], {
|
|
110
|
-
stdout: "pipe",
|
|
111
|
-
stderr: "pipe",
|
|
112
|
-
});
|
|
113
|
-
if (result.exitCode !== 0) {
|
|
114
|
-
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
115
|
-
throw new Error(`git diff falhou (base='${base}', head='${head}'): ${stderr}`);
|
|
116
|
-
}
|
|
117
|
-
return new TextDecoder().decode(result.stdout);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
91
|
// ---------------------------------------------------------------------------
|
|
121
92
|
// Parsing de `git diff --name-status`
|
|
122
93
|
// ---------------------------------------------------------------------------
|
|
@@ -248,7 +219,7 @@ export function classifyRange<TId extends string>(
|
|
|
248
219
|
config: HoundsmanConfig<TId>,
|
|
249
220
|
diff?: DiffSource,
|
|
250
221
|
): ClassificationResult {
|
|
251
|
-
const entries = parseEntries(
|
|
222
|
+
const entries = parseEntries(runGit(["diff", `${base}...${head}`, "--name-status"]));
|
|
252
223
|
const churn = diff?.churn ?? collectChurn(base, head);
|
|
253
224
|
const result = classifyEntries(entries, config, churnToRecord(churn));
|
|
254
225
|
result.base = base;
|
|
@@ -257,39 +228,12 @@ export function classifyRange<TId extends string>(
|
|
|
257
228
|
}
|
|
258
229
|
|
|
259
230
|
// ---------------------------------------------------------------------------
|
|
260
|
-
// Triage (PRs grandes)
|
|
231
|
+
// Triage (PRs grandes) — reexportado do domínio
|
|
261
232
|
// ---------------------------------------------------------------------------
|
|
262
233
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
/** Ordena os arquivos por risco (churn desc; empate pelo caminho). */
|
|
270
|
-
export function sortByRisk(files: readonly string[], churn?: ChurnMap): string[] {
|
|
271
|
-
return [...files].sort((a, b) => {
|
|
272
|
-
const delta = churnOf(b, churn) - churnOf(a, churn);
|
|
273
|
-
return delta !== 0 ? delta : a.localeCompare(b);
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Resultado da aplicação do orçamento: o que revisar e o que ficou de fora. */
|
|
278
|
-
export type BudgetSelection = { selected: string[]; overflow: string[] };
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Aplica o orçamento por brief: mantém os `maxFiles` de maior risco e devolve o
|
|
282
|
-
* restante em `overflow` (nunca descarta silenciosamente). `maxFiles` não-positivo
|
|
283
|
-
* ou não-finito desativa o orçamento.
|
|
284
|
-
*/
|
|
285
|
-
export function selectByBudget(
|
|
286
|
-
files: readonly string[],
|
|
287
|
-
churn: ChurnMap | undefined,
|
|
288
|
-
maxFiles: number,
|
|
289
|
-
): BudgetSelection {
|
|
290
|
-
const ordered = sortByRisk(files, churn);
|
|
291
|
-
if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
|
|
292
|
-
return { selected: ordered, overflow: [] };
|
|
293
|
-
}
|
|
294
|
-
return { selected: ordered.slice(0, maxFiles), overflow: ordered.slice(maxFiles) };
|
|
295
|
-
}
|
|
234
|
+
export {
|
|
235
|
+
type BudgetSelection,
|
|
236
|
+
churnOf,
|
|
237
|
+
selectByBudget,
|
|
238
|
+
sortByRisk,
|
|
239
|
+
} from "./domain/budget";
|