@payconductor/houndsman 0.2.0 → 0.4.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/README.md +18 -6
- 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 +40 -0
- package/src/cli/commands/doctor-command.ts +60 -0
- package/src/cli/commands/findings-command.ts +38 -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 +21 -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 +47 -0
- package/src/cli/index.ts +60 -268
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +185 -260
- package/src/core/commands/review.ts +21 -39
- package/src/core/commands/scaffold.ts +189 -80
- 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 +24 -25
- 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 +65 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +79 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +193 -100
- 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/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +247 -421
- package/src/core/paths.ts +26 -11
- package/src/core/pr-context.ts +39 -51
- package/src/core/project-config.ts +56 -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 +123 -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 +8 -0
- package/src/core/run-status.ts +29 -59
- 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 +139 -0
- package/src/core/services/project-files.ts +91 -0
- package/src/core/services/verifier.ts +58 -0
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +269 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +160 -0
- package/src/core/storage/sqlite/schema.ts +108 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
- package/src/core/storage/types.ts +143 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +57 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/ArtifactsPanel.tsx +137 -23
- package/src/server/client/FindingCard.tsx +37 -6
- package/src/server/client/MarkdownText.tsx +101 -0
- package/src/server/client/SubmitModal.tsx +9 -4
- package/src/server/client/code-highlight.ts +422 -0
- package/src/server/client/constants.ts +130 -24
- package/src/server/client/json-highlight.ts +55 -0
- package/src/server/client/markdown.ts +153 -0
- package/src/server/client/types.ts +23 -0
- package/src/server/index.ts +155 -245
- package/src/server/run-repository.ts +299 -0
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +27 -18
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +5 -5
- package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
- package/src/tests/baseline.test.ts +555 -493
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +6 -3
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/infrastructure.test.ts +205 -0
- package/src/tests/markdown.test.ts +89 -0
- package/src/tests/review-scripts.test.ts +8 -5
- package/src/tests/run-status.test.ts +33 -23
- package/src/tests/server.test.ts +108 -75
- package/src/tests/storage.test.ts +386 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessão de uma run (engine) — amarra opções, branch, storage e staging.
|
|
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 ReviewContext,
|
|
14
|
+
type ReviewOptions,
|
|
15
|
+
resolveBranch,
|
|
16
|
+
reviewContext,
|
|
17
|
+
} from "../orchestrator";
|
|
18
|
+
import { sanitizeBranch } from "../report-builder";
|
|
19
|
+
import type { RepoIdentity, StorageProvider } from "../storage/types";
|
|
20
|
+
|
|
21
|
+
/** Uma revisão em andamento (prepare ou finalize). */
|
|
22
|
+
export class ReviewSession {
|
|
23
|
+
readonly config: HoundsmanConfig;
|
|
24
|
+
readonly options: ReviewOptions;
|
|
25
|
+
readonly branch: string;
|
|
26
|
+
readonly context: ReviewContext;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
config: HoundsmanConfig,
|
|
30
|
+
options: ReviewOptions,
|
|
31
|
+
branch: string,
|
|
32
|
+
storage: StorageProvider,
|
|
33
|
+
repo: RepoIdentity,
|
|
34
|
+
) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.options = options;
|
|
37
|
+
this.branch = branch;
|
|
38
|
+
const review = options.finalize
|
|
39
|
+
? storage.findReview(repo.key, branch)
|
|
40
|
+
: storage.getOrCreateReview({
|
|
41
|
+
repo,
|
|
42
|
+
branch,
|
|
43
|
+
slug: sanitizeBranch(branch),
|
|
44
|
+
base: options.base,
|
|
45
|
+
head: options.head,
|
|
46
|
+
});
|
|
47
|
+
if (!review) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Nenhuma revisão para '${branch}'. Rode \`houndsman\` (preparar) primeiro.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
this.context = reviewContext(storage, review);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve a branch (opção explícita ou a atual do git). */
|
|
56
|
+
static from(
|
|
57
|
+
config: HoundsmanConfig,
|
|
58
|
+
options: ReviewOptions,
|
|
59
|
+
storage: StorageProvider,
|
|
60
|
+
repo: RepoIdentity,
|
|
61
|
+
): ReviewSession {
|
|
62
|
+
return new ReviewSession(config, options, resolveBranch(options), storage, repo);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Passo 1 — preparar. */
|
|
66
|
+
prepare(): Promise<PrepareResult> {
|
|
67
|
+
return prepare(this.options, this.branch, this.config, this.context);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Passo 3 — finalizar. */
|
|
71
|
+
finalize(): FinalizeResult {
|
|
72
|
+
return finalize(this.options, this.branch, this.config, this.context);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Executa o passo escolhido em `--finalize`. */
|
|
76
|
+
run(): Promise<PrepareResult | FinalizeResult> {
|
|
77
|
+
return this.options.finalize ? Promise.resolve(this.finalize()) : this.prepare();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -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";
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Estado de triagem dos findings (biblioteca).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `fingerprint` de conteúdo: nas re-execuções do `--finalize`
|
|
7
|
-
* preservado casando por fingerprint.
|
|
4
|
+
* O dump (`state.json`) é um detalhe do provider de arquivos; no SQLite o
|
|
5
|
+
* estado é reconstruído de `findings` + `finding_history`. Cada finding tem um
|
|
6
|
+
* `id` curto e um `fingerprint` de conteúdo: nas re-execuções do `--finalize`
|
|
7
|
+
* o estado é preservado casando por fingerprint.
|
|
8
8
|
*
|
|
9
9
|
* A `line` é cache — `syncState` a recalcula após edições (auto-fix) e marca
|
|
10
10
|
* `stale` quando a âncora some.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { dirname, join } from "node:path";
|
|
16
14
|
import {
|
|
17
15
|
buildAnchor,
|
|
18
16
|
type FindingAnchor,
|
|
@@ -20,16 +18,13 @@ import {
|
|
|
20
18
|
locateAnchor,
|
|
21
19
|
reanchor,
|
|
22
20
|
} from "./finding-anchor";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
type MergedFinding,
|
|
27
|
-
parseWatchdogReport,
|
|
28
|
-
sanitizeBranch,
|
|
29
|
-
type WatchdogReport,
|
|
30
|
-
} from "./report-builder";
|
|
21
|
+
import { type MergedFinding, sanitizeBranch } from "./report-builder";
|
|
22
|
+
import { systemClock } from "./services/clock";
|
|
23
|
+
import { type FileReader, ProjectFiles } from "./services/project-files";
|
|
31
24
|
import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
|
|
32
25
|
|
|
26
|
+
export type { FileReader };
|
|
27
|
+
|
|
33
28
|
// ---------------------------------------------------------------------------
|
|
34
29
|
// Tipos
|
|
35
30
|
// ---------------------------------------------------------------------------
|
|
@@ -138,29 +133,9 @@ export const FINDINGS_STATE_VERSION = 2;
|
|
|
138
133
|
// Caminhos
|
|
139
134
|
// ---------------------------------------------------------------------------
|
|
140
135
|
|
|
141
|
-
/** Diretório de artefatos da run (branch-slug). */
|
|
142
|
-
export function runDir(artifactDir: string, branch: string): string {
|
|
143
|
-
return join(artifactDir, sanitizeBranch(branch));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Caminho do `state.json` da run. */
|
|
147
|
-
export function stateFile(artifactDir: string, branch: string): string {
|
|
148
|
-
return join(runDir(artifactDir, branch), STATE_FILE);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
|
|
152
|
-
export type FileReader = (relativePath: string) => string | null;
|
|
153
|
-
|
|
154
136
|
/** Leitor padrão a partir do cwd (null se ausente/ilegível). */
|
|
155
137
|
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
|
-
};
|
|
138
|
+
return new ProjectFiles(cwd).reader();
|
|
164
139
|
}
|
|
165
140
|
|
|
166
141
|
/** Id curto aleatório (hex) para um finding novo. */
|
|
@@ -174,7 +149,7 @@ export function emptyState(branch: string): FindingsStateFile {
|
|
|
174
149
|
version: FINDINGS_STATE_VERSION,
|
|
175
150
|
branch,
|
|
176
151
|
slug: sanitizeBranch(branch),
|
|
177
|
-
updatedAt:
|
|
152
|
+
updatedAt: systemClock(),
|
|
178
153
|
order: [],
|
|
179
154
|
findings: {},
|
|
180
155
|
};
|
|
@@ -270,6 +245,32 @@ export function filterFindings(
|
|
|
270
245
|
.sort(compareFindings);
|
|
271
246
|
}
|
|
272
247
|
|
|
248
|
+
/** Resultado da resolução de um id (completo ou prefixo único). */
|
|
249
|
+
export type FindingIdResolution =
|
|
250
|
+
| { status: "found"; id: string }
|
|
251
|
+
| { status: "ambiguous"; matches: string[] }
|
|
252
|
+
| { status: "missing" };
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolve um id de finding: exato primeiro, depois prefixo único.
|
|
256
|
+
* Ambíguo devolve os candidatos para a mensagem de erro.
|
|
257
|
+
*/
|
|
258
|
+
export function resolveFindingId(
|
|
259
|
+
state: FindingsStateFile,
|
|
260
|
+
input: string,
|
|
261
|
+
): FindingIdResolution {
|
|
262
|
+
const needle = input.trim();
|
|
263
|
+
if (needle.length === 0) return { status: "missing" };
|
|
264
|
+
if (state.findings[needle]) return { status: "found", id: needle };
|
|
265
|
+
|
|
266
|
+
const matches = Object.keys(state.findings).filter((id) =>
|
|
267
|
+
id.toLowerCase().startsWith(needle.toLowerCase()),
|
|
268
|
+
);
|
|
269
|
+
if (matches.length === 1) return { status: "found", id: matches[0] };
|
|
270
|
+
if (matches.length > 1) return { status: "ambiguous", matches };
|
|
271
|
+
return { status: "missing" };
|
|
272
|
+
}
|
|
273
|
+
|
|
273
274
|
// ---------------------------------------------------------------------------
|
|
274
275
|
// Construção (finalize)
|
|
275
276
|
// ---------------------------------------------------------------------------
|
|
@@ -297,7 +298,7 @@ export function buildStateFromFindings(
|
|
|
297
298
|
merged: readonly MergedFinding[],
|
|
298
299
|
options: BuildStateOptions,
|
|
299
300
|
): FindingsStateFile {
|
|
300
|
-
const now = options.now ??
|
|
301
|
+
const now = options.now ?? systemClock();
|
|
301
302
|
const previous = options.previous ?? null;
|
|
302
303
|
|
|
303
304
|
const result: Record<string, FindingState> = {};
|
|
@@ -383,47 +384,6 @@ export function buildStateFromFindings(
|
|
|
383
384
|
return state;
|
|
384
385
|
}
|
|
385
386
|
|
|
386
|
-
/**
|
|
387
|
-
* Reconstrói o estado a partir dos artefatos da run (`classification.json` +
|
|
388
|
-
* `findings/<id>.json`), sem exigir `--finalize`. Devolve null se não houver
|
|
389
|
-
* findings gravados.
|
|
390
|
-
*/
|
|
391
|
-
export function buildStateFromRunDir(
|
|
392
|
-
outDir: string,
|
|
393
|
-
branch: string,
|
|
394
|
-
readFile: FileReader,
|
|
395
|
-
): FindingsStateFile | null {
|
|
396
|
-
const classificationPath = join(outDir, "classification.json");
|
|
397
|
-
if (!existsSync(classificationPath)) return null;
|
|
398
|
-
|
|
399
|
-
let activeIds: string[] = [];
|
|
400
|
-
try {
|
|
401
|
-
const classification = JSON.parse(readFileSync(classificationPath, "utf8")) as {
|
|
402
|
-
watchdogs?: Record<string, string[]>;
|
|
403
|
-
};
|
|
404
|
-
activeIds = Object.keys(classification.watchdogs ?? {});
|
|
405
|
-
} catch {
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const reports: WatchdogReport[] = [];
|
|
410
|
-
for (const id of activeIds) {
|
|
411
|
-
const path = join(outDir, "findings", `${id}.json`);
|
|
412
|
-
if (!existsSync(path)) continue;
|
|
413
|
-
try {
|
|
414
|
-
const report = parseWatchdogReport(
|
|
415
|
-
JSON.parse(readFileSync(path, "utf8")),
|
|
416
|
-
path,
|
|
417
|
-
);
|
|
418
|
-
if (report) reports.push(report);
|
|
419
|
-
} catch {
|
|
420
|
-
// Ignora findings ilegíveis — reconstrução best-effort.
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
if (reports.length === 0) return null;
|
|
424
|
-
|
|
425
|
-
return buildStateFromFindings(dedupeFindings(reports), { branch, readFile });
|
|
426
|
-
}
|
|
427
387
|
// ---------------------------------------------------------------------------
|
|
428
388
|
|
|
429
389
|
export type FindingUpdate = {
|
|
@@ -437,7 +397,7 @@ export type FindingUpdate = {
|
|
|
437
397
|
export function applyFindingUpdate(
|
|
438
398
|
finding: FindingState,
|
|
439
399
|
update: FindingUpdate,
|
|
440
|
-
now: string =
|
|
400
|
+
now: string = systemClock(),
|
|
441
401
|
): FindingState {
|
|
442
402
|
if (update.decision && DECISION_SET.has(update.decision)) {
|
|
443
403
|
finding.decision = update.decision;
|
|
@@ -471,7 +431,7 @@ export type SyncResult = {
|
|
|
471
431
|
export function syncState(
|
|
472
432
|
state: FindingsStateFile,
|
|
473
433
|
readFile: FileReader,
|
|
474
|
-
now: string =
|
|
434
|
+
now: string = systemClock(),
|
|
475
435
|
): SyncResult {
|
|
476
436
|
const moved: string[] = [];
|
|
477
437
|
const stale: string[] = [];
|
|
@@ -521,55 +481,188 @@ export function occurrenceCount(
|
|
|
521
481
|
}
|
|
522
482
|
|
|
523
483
|
// ---------------------------------------------------------------------------
|
|
524
|
-
//
|
|
484
|
+
// Domínio (classes)
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
/** Um finding triável, com comportamento sobre o DTO serializável. */
|
|
488
|
+
export class Finding {
|
|
489
|
+
readonly state: FindingState;
|
|
490
|
+
|
|
491
|
+
constructor(state: FindingState) {
|
|
492
|
+
this.state = state;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
static from(state: FindingState): Finding {
|
|
496
|
+
return new Finding(state);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
get id(): string {
|
|
500
|
+
return this.state.id;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
get file(): string {
|
|
504
|
+
return this.state.file;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
get line(): number | null {
|
|
508
|
+
return this.state.line;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
get severity(): Severity {
|
|
512
|
+
return this.state.severity;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
get decision(): FindingDecision {
|
|
516
|
+
return this.state.decision;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
get execState(): FindingExecState {
|
|
520
|
+
return this.state.execState;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
get present(): boolean {
|
|
524
|
+
return this.state.present;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
get isDone(): boolean {
|
|
528
|
+
return this.state.execState === "done";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Aplica uma atualização (com histórico) e devolve o próprio finding. */
|
|
532
|
+
apply(update: FindingUpdate, now: string = systemClock()): Finding {
|
|
533
|
+
applyFindingUpdate(this.state, update, now);
|
|
534
|
+
return this;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Payload serializável (mesmo shape do `state.json`). */
|
|
538
|
+
toJSON(): FindingState {
|
|
539
|
+
return this.state;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Coleção de triagem de uma run — superfície OO sobre o dump persistido. */
|
|
544
|
+
export class FindingsState {
|
|
545
|
+
readonly file: FindingsStateFile;
|
|
546
|
+
|
|
547
|
+
constructor(file: FindingsStateFile) {
|
|
548
|
+
this.file = file;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
static from(file: FindingsStateFile): FindingsState {
|
|
552
|
+
return new FindingsState(file);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
static empty(branch: string): FindingsState {
|
|
556
|
+
return new FindingsState(emptyState(branch));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
get branch(): string {
|
|
560
|
+
return this.file.branch;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
get reviewed(): FindingsReviewed | undefined {
|
|
564
|
+
return this.file.reviewed;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Findings na ordem de exibição (`order`). */
|
|
568
|
+
list(): Finding[] {
|
|
569
|
+
return this.file.order
|
|
570
|
+
.map((id) => this.file.findings[id])
|
|
571
|
+
.filter((state): state is FindingState => Boolean(state))
|
|
572
|
+
.map((state) => Finding.from(state));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Busca por id. */
|
|
576
|
+
get(id: string): Finding | null {
|
|
577
|
+
const state = this.file.findings[id];
|
|
578
|
+
return state ? Finding.from(state) : null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Todos os findings (ordem de inserção, sem o `order`). */
|
|
582
|
+
values(): Finding[] {
|
|
583
|
+
return Object.values(this.file.findings).map((state) => Finding.from(state));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Somente os presentes no último relatório. */
|
|
587
|
+
present(): Finding[] {
|
|
588
|
+
return this.values().filter((finding) => finding.present);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Filtra pela ordem canônica (presentes → severidade → arquivo → linha). */
|
|
592
|
+
filter(filter: FindingsFilter = {}): Finding[] {
|
|
593
|
+
return filterFindings(this.file, filter).map((state) => Finding.from(state));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Contagens por decisão, execução e severidade. */
|
|
597
|
+
summarize(): FindingsSummary {
|
|
598
|
+
return summarize(this.file);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Reordena os ids pela ordenação canônica. */
|
|
602
|
+
reorder(): void {
|
|
603
|
+
reorder(this.file);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Aplica uma atualização a um finding e devolve-o (null se ausente). */
|
|
607
|
+
apply(id: string, update: FindingUpdate, now?: string): Finding | null {
|
|
608
|
+
const state = this.file.findings[id];
|
|
609
|
+
if (!state) return null;
|
|
610
|
+
applyFindingUpdate(state, update, now);
|
|
611
|
+
return Finding.from(state);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Reancora os presentes contra os arquivos atuais. */
|
|
615
|
+
sync(readFile: FileReader, now?: string): SyncResult {
|
|
616
|
+
return syncState(this.file, readFile, now);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Payload serializável (mesmo shape do `state.json`). */
|
|
620
|
+
toJSON(): FindingsStateFile {
|
|
621
|
+
return this.file;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// Repository (I/O atômico)
|
|
525
627
|
// ---------------------------------------------------------------------------
|
|
526
628
|
|
|
527
629
|
/** Persistência do `state.json` — escrita atômica (tmp + rename). */
|
|
528
|
-
export class
|
|
630
|
+
export class FindingsRepository {
|
|
529
631
|
private readonly path: string;
|
|
530
632
|
private readonly branch: string;
|
|
633
|
+
private readonly files = new ProjectFiles();
|
|
531
634
|
|
|
532
635
|
constructor(path: string, branch: string) {
|
|
533
636
|
this.path = path;
|
|
534
637
|
this.branch = branch;
|
|
535
638
|
}
|
|
536
639
|
|
|
537
|
-
/** Store para a run de uma branch. */
|
|
538
|
-
static at(artifactDir: string, branch: string): FindingsStateStore {
|
|
539
|
-
return new FindingsStateStore(stateFile(artifactDir, branch), branch);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
640
|
get filePath(): string {
|
|
543
641
|
return this.path;
|
|
544
642
|
}
|
|
545
643
|
|
|
546
644
|
/** Lê o estado; null se ausente/inválido. */
|
|
547
645
|
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
|
-
}
|
|
646
|
+
const raw = this.files.readJson<FindingsStateFile>(this.path);
|
|
647
|
+
if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
648
|
+
return raw;
|
|
556
649
|
}
|
|
557
650
|
|
|
558
651
|
/** Grava o estado de forma atômica. */
|
|
559
652
|
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);
|
|
653
|
+
this.files.writeAtomic(this.path, `${JSON.stringify(state, null, 2)}\n`);
|
|
564
654
|
}
|
|
565
655
|
|
|
566
656
|
/** Lê-modifica-grava; parte de um estado vazio se não existir. */
|
|
567
657
|
update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
|
|
568
658
|
const state = this.read() ?? emptyState(this.branch);
|
|
569
659
|
mutator(state);
|
|
570
|
-
state.updatedAt =
|
|
660
|
+
state.updatedAt = systemClock();
|
|
571
661
|
reorder(state);
|
|
572
662
|
this.write(state);
|
|
573
663
|
return state;
|
|
574
664
|
}
|
|
575
665
|
}
|
|
666
|
+
|
|
667
|
+
/** Nome anterior do repository (compatibilidade da API pública). */
|
|
668
|
+
export { FindingsRepository as FindingsStateStore };
|
package/src/core/git-revision.ts
CHANGED
|
@@ -11,31 +11,20 @@
|
|
|
11
11
|
* (`parseEntries`) — não reimplementa leitura de diff.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
DEFAULT_MAX_PER_FILE_CHARS,
|
|
16
|
-
type DiffSource,
|
|
17
|
-
parseDiffIndex,
|
|
18
|
-
parseNumstat,
|
|
19
|
-
} from "./diff-source";
|
|
14
|
+
import { DEFAULT_MAX_PER_FILE_CHARS, type DiffSource } from "./diff-source";
|
|
20
15
|
import { parseEntries } from "./file-classifier";
|
|
16
|
+
import { runGit } from "./git-runner";
|
|
17
|
+
import { GitRepository } from "./services/git-repository";
|
|
21
18
|
import type { FileChurn } from "./types";
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
stderr: "pipe",
|
|
28
|
-
});
|
|
29
|
-
if (result.exitCode !== 0) {
|
|
30
|
-
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
31
|
-
throw new Error(`git ${args.join(" ")} falhou: ${stderr}`);
|
|
32
|
-
}
|
|
33
|
-
return new TextDecoder().decode(result.stdout);
|
|
34
|
-
}
|
|
20
|
+
export { runGit };
|
|
21
|
+
|
|
22
|
+
/** Repositório padrão (runner real) usado pelos helpers funcionais. */
|
|
23
|
+
const git = new GitRepository();
|
|
35
24
|
|
|
36
25
|
/** SHA completo de uma ref (`git rev-parse`). Lança se a ref não resolver. */
|
|
37
26
|
export function resolveSha(ref: string): string {
|
|
38
|
-
return
|
|
27
|
+
return git.resolveSha(ref);
|
|
39
28
|
}
|
|
40
29
|
|
|
41
30
|
/** SHA do commit atual (`HEAD`). */
|
|
@@ -50,31 +39,22 @@ export function currentHeadSha(): string {
|
|
|
50
39
|
* comportamento seguro.
|
|
51
40
|
*/
|
|
52
41
|
export function isAncestor(ancestor: string, descendant: string): boolean {
|
|
53
|
-
|
|
54
|
-
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
55
|
-
{ stdout: "ignore", stderr: "ignore" },
|
|
56
|
-
);
|
|
57
|
-
return result.exitCode === 0;
|
|
42
|
+
return git.isAncestor(ancestor, descendant);
|
|
58
43
|
}
|
|
59
44
|
|
|
60
45
|
/** Conteúdo de um arquivo num commit (`git show sha:path`); null se ausente. */
|
|
61
46
|
export function readFileAt(sha: string, path: string): string | null {
|
|
62
|
-
|
|
63
|
-
["git", "-c", "core.quotepath=false", "show", `${sha}:${path}`],
|
|
64
|
-
{ stdout: "pipe", stderr: "ignore" },
|
|
65
|
-
);
|
|
66
|
-
if (result.exitCode !== 0) return null;
|
|
67
|
-
return new TextDecoder().decode(result.stdout);
|
|
47
|
+
return git.readFileAt(sha, path);
|
|
68
48
|
}
|
|
69
49
|
|
|
70
50
|
/** Arquivos alterados num intervalo (`git diff range --name-status`). */
|
|
71
51
|
export function diffEntries(range: string): ReturnType<typeof parseEntries> {
|
|
72
|
-
return parseEntries(
|
|
52
|
+
return parseEntries(git.run(["diff", range, "--name-status"]));
|
|
73
53
|
}
|
|
74
54
|
|
|
75
55
|
/** Churn por arquivo de um intervalo (`git diff --numstat -z range`). */
|
|
76
56
|
export function diffNumstat(range: string): Map<string, FileChurn> {
|
|
77
|
-
return
|
|
57
|
+
return git.diffNumstat(range);
|
|
78
58
|
}
|
|
79
59
|
|
|
80
60
|
/**
|
|
@@ -86,9 +66,7 @@ export function diffPatch(
|
|
|
86
66
|
paths?: readonly string[],
|
|
87
67
|
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
88
68
|
): Map<string, string> {
|
|
89
|
-
|
|
90
|
-
if (paths && paths.length > 0) args.push("--", ...paths);
|
|
91
|
-
return parseDiffIndex(runGit(args), maxPerFileChars);
|
|
69
|
+
return git.diffPatch(range, paths, maxPerFileChars);
|
|
92
70
|
}
|
|
93
71
|
|
|
94
72
|
/** Diff (patch + churn) de `from..to` — 2 spawns, base do review incremental. */
|
|
@@ -97,9 +75,5 @@ export function collectDiffSince(
|
|
|
97
75
|
to: string,
|
|
98
76
|
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
99
77
|
): DiffSource {
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
byFile: parseDiffIndex(runGit(["diff", range]), maxPerFileChars),
|
|
103
|
-
churn: diffNumstat(range),
|
|
104
|
-
};
|
|
78
|
+
return git.collectDiffSince(from, to, maxPerFileChars);
|
|
105
79
|
}
|