@payconductor/houndsman 0.2.0 → 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 +58 -266
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/findings.ts +34 -144
- 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 +170 -38
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +4 -33
- 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/index.ts +64 -234
- 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 -493
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/infrastructure.test.ts +210 -0
|
@@ -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";
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync,
|
|
15
|
-
import {
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
16
|
import {
|
|
17
17
|
buildAnchor,
|
|
18
18
|
type FindingAnchor,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
locateAnchor,
|
|
21
21
|
reanchor,
|
|
22
22
|
} from "./finding-anchor";
|
|
23
|
-
import { STATE_FILE } from "./paths";
|
|
24
23
|
import {
|
|
25
24
|
dedupeFindings,
|
|
26
25
|
type MergedFinding,
|
|
@@ -28,8 +27,13 @@ import {
|
|
|
28
27
|
sanitizeBranch,
|
|
29
28
|
type WatchdogReport,
|
|
30
29
|
} from "./report-builder";
|
|
30
|
+
import { RunLayout } from "./run-layout";
|
|
31
|
+
import { systemClock } from "./services/clock";
|
|
32
|
+
import { type FileReader, ProjectFiles } from "./services/project-files";
|
|
31
33
|
import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
|
|
32
34
|
|
|
35
|
+
export type { FileReader };
|
|
36
|
+
|
|
33
37
|
// ---------------------------------------------------------------------------
|
|
34
38
|
// Tipos
|
|
35
39
|
// ---------------------------------------------------------------------------
|
|
@@ -140,27 +144,17 @@ export const FINDINGS_STATE_VERSION = 2;
|
|
|
140
144
|
|
|
141
145
|
/** Diretório de artefatos da run (branch-slug). */
|
|
142
146
|
export function runDir(artifactDir: string, branch: string): string {
|
|
143
|
-
return
|
|
147
|
+
return new RunLayout(artifactDir, branch).outDir;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/** Caminho do `state.json` da run. */
|
|
147
151
|
export function stateFile(artifactDir: string, branch: string): string {
|
|
148
|
-
return
|
|
152
|
+
return new RunLayout(artifactDir, branch).stateFile;
|
|
149
153
|
}
|
|
150
154
|
|
|
151
|
-
/** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
|
|
152
|
-
export type FileReader = (relativePath: string) => string | null;
|
|
153
|
-
|
|
154
155
|
/** Leitor padrão a partir do cwd (null se ausente/ilegível). */
|
|
155
156
|
export function fileReader(cwd: string): FileReader {
|
|
156
|
-
return (
|
|
157
|
-
try {
|
|
158
|
-
const full = join(cwd, relativePath);
|
|
159
|
-
return existsSync(full) ? readFileSync(full, "utf8") : null;
|
|
160
|
-
} catch {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
};
|
|
157
|
+
return new ProjectFiles(cwd).reader();
|
|
164
158
|
}
|
|
165
159
|
|
|
166
160
|
/** Id curto aleatório (hex) para um finding novo. */
|
|
@@ -174,7 +168,7 @@ export function emptyState(branch: string): FindingsStateFile {
|
|
|
174
168
|
version: FINDINGS_STATE_VERSION,
|
|
175
169
|
branch,
|
|
176
170
|
slug: sanitizeBranch(branch),
|
|
177
|
-
updatedAt:
|
|
171
|
+
updatedAt: systemClock(),
|
|
178
172
|
order: [],
|
|
179
173
|
findings: {},
|
|
180
174
|
};
|
|
@@ -297,7 +291,7 @@ export function buildStateFromFindings(
|
|
|
297
291
|
merged: readonly MergedFinding[],
|
|
298
292
|
options: BuildStateOptions,
|
|
299
293
|
): FindingsStateFile {
|
|
300
|
-
const now = options.now ??
|
|
294
|
+
const now = options.now ?? systemClock();
|
|
301
295
|
const previous = options.previous ?? null;
|
|
302
296
|
|
|
303
297
|
const result: Record<string, FindingState> = {};
|
|
@@ -437,7 +431,7 @@ export type FindingUpdate = {
|
|
|
437
431
|
export function applyFindingUpdate(
|
|
438
432
|
finding: FindingState,
|
|
439
433
|
update: FindingUpdate,
|
|
440
|
-
now: string =
|
|
434
|
+
now: string = systemClock(),
|
|
441
435
|
): FindingState {
|
|
442
436
|
if (update.decision && DECISION_SET.has(update.decision)) {
|
|
443
437
|
finding.decision = update.decision;
|
|
@@ -471,7 +465,7 @@ export type SyncResult = {
|
|
|
471
465
|
export function syncState(
|
|
472
466
|
state: FindingsStateFile,
|
|
473
467
|
readFile: FileReader,
|
|
474
|
-
now: string =
|
|
468
|
+
now: string = systemClock(),
|
|
475
469
|
): SyncResult {
|
|
476
470
|
const moved: string[] = [];
|
|
477
471
|
const stale: string[] = [];
|
|
@@ -521,22 +515,165 @@ export function occurrenceCount(
|
|
|
521
515
|
}
|
|
522
516
|
|
|
523
517
|
// ---------------------------------------------------------------------------
|
|
524
|
-
//
|
|
518
|
+
// Domínio (classes)
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
/** Um finding triável, com comportamento sobre o DTO serializável. */
|
|
522
|
+
export class Finding {
|
|
523
|
+
readonly state: FindingState;
|
|
524
|
+
|
|
525
|
+
constructor(state: FindingState) {
|
|
526
|
+
this.state = state;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
static from(state: FindingState): Finding {
|
|
530
|
+
return new Finding(state);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
get id(): string {
|
|
534
|
+
return this.state.id;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
get file(): string {
|
|
538
|
+
return this.state.file;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
get line(): number | null {
|
|
542
|
+
return this.state.line;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
get severity(): Severity {
|
|
546
|
+
return this.state.severity;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
get decision(): FindingDecision {
|
|
550
|
+
return this.state.decision;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
get execState(): FindingExecState {
|
|
554
|
+
return this.state.execState;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
get present(): boolean {
|
|
558
|
+
return this.state.present;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
get isDone(): boolean {
|
|
562
|
+
return this.state.execState === "done";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Aplica uma atualização (com histórico) e devolve o próprio finding. */
|
|
566
|
+
apply(update: FindingUpdate, now: string = systemClock()): Finding {
|
|
567
|
+
applyFindingUpdate(this.state, update, now);
|
|
568
|
+
return this;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Payload serializável (mesmo shape do `state.json`). */
|
|
572
|
+
toJSON(): FindingState {
|
|
573
|
+
return this.state;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Coleção de triagem de uma run — superfície OO sobre o `state.json`. */
|
|
578
|
+
export class FindingsState {
|
|
579
|
+
readonly file: FindingsStateFile;
|
|
580
|
+
|
|
581
|
+
constructor(file: FindingsStateFile) {
|
|
582
|
+
this.file = file;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
static from(file: FindingsStateFile): FindingsState {
|
|
586
|
+
return new FindingsState(file);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
static empty(branch: string): FindingsState {
|
|
590
|
+
return new FindingsState(emptyState(branch));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
get branch(): string {
|
|
594
|
+
return this.file.branch;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
get reviewed(): FindingsReviewed | undefined {
|
|
598
|
+
return this.file.reviewed;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Findings na ordem de exibição (`order`). */
|
|
602
|
+
list(): Finding[] {
|
|
603
|
+
return this.file.order
|
|
604
|
+
.map((id) => this.file.findings[id])
|
|
605
|
+
.filter((state): state is FindingState => Boolean(state))
|
|
606
|
+
.map((state) => Finding.from(state));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Busca por id. */
|
|
610
|
+
get(id: string): Finding | null {
|
|
611
|
+
const state = this.file.findings[id];
|
|
612
|
+
return state ? Finding.from(state) : null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Todos os findings (ordem de inserção, sem o `order`). */
|
|
616
|
+
values(): Finding[] {
|
|
617
|
+
return Object.values(this.file.findings).map((state) => Finding.from(state));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Somente os presentes no último relatório. */
|
|
621
|
+
present(): Finding[] {
|
|
622
|
+
return this.values().filter((finding) => finding.present);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Filtra pela ordem canônica (presentes → severidade → arquivo → linha). */
|
|
626
|
+
filter(filter: FindingsFilter = {}): Finding[] {
|
|
627
|
+
return filterFindings(this.file, filter).map((state) => Finding.from(state));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Contagens por decisão, execução e severidade. */
|
|
631
|
+
summarize(): FindingsSummary {
|
|
632
|
+
return summarize(this.file);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Reordena os ids pela ordenação canônica. */
|
|
636
|
+
reorder(): void {
|
|
637
|
+
reorder(this.file);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Aplica uma atualização a um finding e devolve-o (null se ausente). */
|
|
641
|
+
apply(id: string, update: FindingUpdate, now?: string): Finding | null {
|
|
642
|
+
const state = this.file.findings[id];
|
|
643
|
+
if (!state) return null;
|
|
644
|
+
applyFindingUpdate(state, update, now);
|
|
645
|
+
return Finding.from(state);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Reancora os presentes contra os arquivos atuais. */
|
|
649
|
+
sync(readFile: FileReader, now?: string): SyncResult {
|
|
650
|
+
return syncState(this.file, readFile, now);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Payload serializável (mesmo shape do `state.json`). */
|
|
654
|
+
toJSON(): FindingsStateFile {
|
|
655
|
+
return this.file;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Repository (I/O atômico)
|
|
525
661
|
// ---------------------------------------------------------------------------
|
|
526
662
|
|
|
527
663
|
/** Persistência do `state.json` — escrita atômica (tmp + rename). */
|
|
528
|
-
export class
|
|
664
|
+
export class FindingsRepository {
|
|
529
665
|
private readonly path: string;
|
|
530
666
|
private readonly branch: string;
|
|
667
|
+
private readonly files = new ProjectFiles();
|
|
531
668
|
|
|
532
669
|
constructor(path: string, branch: string) {
|
|
533
670
|
this.path = path;
|
|
534
671
|
this.branch = branch;
|
|
535
672
|
}
|
|
536
673
|
|
|
537
|
-
/**
|
|
538
|
-
static at(artifactDir: string, branch: string):
|
|
539
|
-
return new
|
|
674
|
+
/** Repository para a run de uma branch. */
|
|
675
|
+
static at(artifactDir: string, branch: string): FindingsRepository {
|
|
676
|
+
return new FindingsRepository(stateFile(artifactDir, branch), branch);
|
|
540
677
|
}
|
|
541
678
|
|
|
542
679
|
get filePath(): string {
|
|
@@ -545,31 +682,26 @@ export class FindingsStateStore {
|
|
|
545
682
|
|
|
546
683
|
/** Lê o estado; null se ausente/inválido. */
|
|
547
684
|
read(): FindingsStateFile | null {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (raw?.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
552
|
-
return raw;
|
|
553
|
-
} catch {
|
|
554
|
-
return null;
|
|
555
|
-
}
|
|
685
|
+
const raw = this.files.readJson<FindingsStateFile>(this.path);
|
|
686
|
+
if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
687
|
+
return raw;
|
|
556
688
|
}
|
|
557
689
|
|
|
558
690
|
/** Grava o estado de forma atômica. */
|
|
559
691
|
write(state: FindingsStateFile): void {
|
|
560
|
-
|
|
561
|
-
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
562
|
-
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
563
|
-
renameSync(tmp, this.path);
|
|
692
|
+
this.files.writeAtomic(this.path, `${JSON.stringify(state, null, 2)}\n`);
|
|
564
693
|
}
|
|
565
694
|
|
|
566
695
|
/** Lê-modifica-grava; parte de um estado vazio se não existir. */
|
|
567
696
|
update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
|
|
568
697
|
const state = this.read() ?? emptyState(this.branch);
|
|
569
698
|
mutator(state);
|
|
570
|
-
state.updatedAt =
|
|
699
|
+
state.updatedAt = systemClock();
|
|
571
700
|
reorder(state);
|
|
572
701
|
this.write(state);
|
|
573
702
|
return state;
|
|
574
703
|
}
|
|
575
704
|
}
|
|
705
|
+
|
|
706
|
+
/** Nome anterior do repository (compatibilidade da API pública). */
|
|
707
|
+
export { FindingsRepository as FindingsStateStore };
|