@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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { JevCandidate } from "./jev";
|
|
11
|
+
import { systemClock } from "./services/clock";
|
|
11
12
|
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity, severityRank } from "./types";
|
|
12
13
|
|
|
13
14
|
/** Resolve o nome legível de um watchdog (default: identidade). */
|
|
@@ -106,24 +107,28 @@ function isFinding(value: unknown): value is Finding {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** Valida e normaliza um relatório. Retorna null se inválido. */
|
|
109
|
-
export function parseWatchdogReport(
|
|
110
|
+
export function parseWatchdogReport(
|
|
111
|
+
raw: unknown,
|
|
112
|
+
source: string,
|
|
113
|
+
onError: (message: string) => void = () => {},
|
|
114
|
+
): WatchdogReport | null {
|
|
110
115
|
if (typeof raw !== "object" || raw === null) {
|
|
111
|
-
|
|
116
|
+
onError(`[report-builder] ${source}: não é um objeto JSON`);
|
|
112
117
|
return null;
|
|
113
118
|
}
|
|
114
119
|
const obj = raw as Record<string, unknown>;
|
|
115
120
|
if (typeof obj.watchdogId !== "string") {
|
|
116
|
-
|
|
121
|
+
onError(`[report-builder] ${source}: campo 'watchdogId' ausente`);
|
|
117
122
|
return null;
|
|
118
123
|
}
|
|
119
124
|
if (!Array.isArray(obj.findings)) {
|
|
120
|
-
|
|
125
|
+
onError(`[report-builder] ${source}: campo 'findings' ausente`);
|
|
121
126
|
return null;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
const findings = obj.findings.filter((f) => {
|
|
125
130
|
if (!isFinding(f)) {
|
|
126
|
-
|
|
131
|
+
onError(`[report-builder] ${source}: finding inválido ignorado`);
|
|
127
132
|
return false;
|
|
128
133
|
}
|
|
129
134
|
return true;
|
|
@@ -370,7 +375,7 @@ export function renderDomainReport(
|
|
|
370
375
|
context?: ContextSummary | null,
|
|
371
376
|
nameOf: WatchdogNameResolver = identityName,
|
|
372
377
|
): string {
|
|
373
|
-
const now =
|
|
378
|
+
const now = systemClock().slice(0, 10);
|
|
374
379
|
const name = watchdogName(nameOf, report.watchdogId);
|
|
375
380
|
const lines: string[] = [
|
|
376
381
|
`# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
|
|
@@ -429,7 +434,7 @@ export function renderReport(
|
|
|
429
434
|
nameOf: WatchdogNameResolver = identityName,
|
|
430
435
|
domains: readonly DomainReport[] = [],
|
|
431
436
|
): string {
|
|
432
|
-
const now =
|
|
437
|
+
const now = systemClock().slice(0, 10);
|
|
433
438
|
const counts = {
|
|
434
439
|
blocking: merged.filter((f) => f.severity === "blocking").length,
|
|
435
440
|
attention: merged.filter((f) => f.severity === "attention").length,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout de uma run (biblioteca) — único ponto que conhece os nomes/estrutura
|
|
3
|
+
* dos artefatos em `.houndsman/.runs/<slug>/`.
|
|
4
|
+
*
|
|
5
|
+
* Evita o "data clump" de `(dir, branch)`/`(outDir, branch)` espalhado pelo
|
|
6
|
+
* motor: quem precisa de um caminho pede ao layout.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
CONTEXT_BRIEF_FILE,
|
|
12
|
+
CONTEXT_FILE,
|
|
13
|
+
CONTEXT_SUMMARY_FILE,
|
|
14
|
+
RUN_STATUS_FILE,
|
|
15
|
+
STATE_FILE,
|
|
16
|
+
VERIFY_FILE,
|
|
17
|
+
} from "./paths";
|
|
18
|
+
import { sanitizeBranch } from "./report-builder";
|
|
19
|
+
|
|
20
|
+
/** Caminhos dos artefatos de contexto da PR. */
|
|
21
|
+
export type ContextPaths = {
|
|
22
|
+
dir: string;
|
|
23
|
+
context: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
brief: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Diretório e caminhos de uma run (branch-slug). */
|
|
29
|
+
export class RunLayout {
|
|
30
|
+
readonly artifactDir: string;
|
|
31
|
+
readonly branch: string;
|
|
32
|
+
|
|
33
|
+
constructor(artifactDir: string, branch: string) {
|
|
34
|
+
this.artifactDir = artifactDir;
|
|
35
|
+
this.branch = branch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get slug(): string {
|
|
39
|
+
return sanitizeBranch(this.branch);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get outDir(): string {
|
|
43
|
+
return join(this.artifactDir, this.slug);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get briefsDir(): string {
|
|
47
|
+
return join(this.outDir, "briefs");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get findingsDir(): string {
|
|
51
|
+
return join(this.outDir, "findings");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Caminho de um arquivo dentro da pasta da run. */
|
|
55
|
+
file(name: string): string {
|
|
56
|
+
return join(this.outDir, name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
briefFile(id: string): string {
|
|
60
|
+
return join(this.briefsDir, `${id}.md`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
findingFile(id: string): string {
|
|
64
|
+
return join(this.findingsDir, `${id}.json`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get classificationFile(): string {
|
|
68
|
+
return this.file("classification.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get manifestFile(): string {
|
|
72
|
+
return this.file("manifest.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get candidatesFile(): string {
|
|
76
|
+
return this.file("candidates.json");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get reviewFile(): string {
|
|
80
|
+
return this.file("review.md");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get stateFile(): string {
|
|
84
|
+
return this.file(STATE_FILE);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get runStatusFile(): string {
|
|
88
|
+
return this.file(RUN_STATUS_FILE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get verifyFile(): string {
|
|
92
|
+
return this.file(VERIFY_FILE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Relatório isolado de um watchdog (`review.<id>.md`). */
|
|
96
|
+
domainReportFile(id: string): string {
|
|
97
|
+
return this.file(`review.${sanitizeBranch(id)}.md`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get contextPaths(): ContextPaths {
|
|
101
|
+
return {
|
|
102
|
+
dir: this.outDir,
|
|
103
|
+
context: this.file(CONTEXT_FILE),
|
|
104
|
+
summary: this.file(CONTEXT_SUMMARY_FILE),
|
|
105
|
+
brief: this.file(CONTEXT_BRIEF_FILE),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/core/run-status.ts
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* não é um status persistido.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
12
|
import { join } from "node:path";
|
|
14
13
|
import type { FindingsStateFile } from "./findings-state";
|
|
15
14
|
import { RUN_STATUS_FILE } from "./paths";
|
|
16
15
|
import { sanitizeBranch } from "./report-builder";
|
|
16
|
+
import { systemClock } from "./services/clock";
|
|
17
|
+
import { ProjectFiles } from "./services/project-files";
|
|
17
18
|
|
|
18
19
|
/** Estados de execução de uma run (persistidos). */
|
|
19
20
|
export type RunStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
|
|
@@ -47,13 +48,7 @@ export function runStatusPath(runDir: string): string {
|
|
|
47
48
|
|
|
48
49
|
/** Lê o `run.json` da run (null se ausente/inválido). */
|
|
49
50
|
export function readRunStatus(runDir: string): RunStatusFile | null {
|
|
50
|
-
|
|
51
|
-
if (!existsSync(path)) return null;
|
|
52
|
-
try {
|
|
53
|
-
return JSON.parse(readFileSync(path, "utf8")) as RunStatusFile;
|
|
54
|
-
} catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
51
|
+
return new ProjectFiles().readJson<RunStatusFile>(runStatusPath(runDir));
|
|
57
52
|
}
|
|
58
53
|
|
|
59
54
|
export type WriteRunStatusOptions = {
|
|
@@ -69,7 +64,8 @@ export function writeRunStatus(
|
|
|
69
64
|
runDir: string,
|
|
70
65
|
options: WriteRunStatusOptions,
|
|
71
66
|
): RunStatusFile {
|
|
72
|
-
const
|
|
67
|
+
const files = new ProjectFiles();
|
|
68
|
+
const now = options.now ?? systemClock();
|
|
73
69
|
const previous = readRunStatus(runDir);
|
|
74
70
|
const file: RunStatusFile = {
|
|
75
71
|
version: RUN_STATUS_VERSION,
|
|
@@ -79,8 +75,7 @@ export function writeRunStatus(
|
|
|
79
75
|
startedAt: options.resetStartedAt ? now : (previous?.startedAt ?? now),
|
|
80
76
|
updatedAt: now,
|
|
81
77
|
};
|
|
82
|
-
|
|
83
|
-
writeFileSync(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
|
|
78
|
+
files.write(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
|
|
84
79
|
return file;
|
|
85
80
|
}
|
|
86
81
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relógio injetável (biblioteca) — timestamps ISO 8601.
|
|
3
|
+
*
|
|
4
|
+
* Centraliza `new Date().toISOString()` para que o domínio não dependa do
|
|
5
|
+
* relógio do sistema e os testes possam congelar o tempo.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Devolve um timestamp ISO 8601. */
|
|
9
|
+
export type Clock = () => string;
|
|
10
|
+
|
|
11
|
+
/** Relógio do sistema (default do motor). */
|
|
12
|
+
export const systemClock: Clock = () => new Date().toISOString();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serviço de classificação (biblioteca) — superfície OO sobre as funções puras.
|
|
3
|
+
*
|
|
4
|
+
* Mantém as funções exportadas (`classifyEntries`/`classifyRange`) para a API
|
|
5
|
+
* pública e os testes; a classe devolve `Classification` (domínio).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HoundsmanConfig } from "../config";
|
|
9
|
+
import type { DiffSource } from "../diff-source";
|
|
10
|
+
import { Classification } from "../domain/classification";
|
|
11
|
+
import { classifyEntries, classifyRange, type parseEntries } from "../file-classifier";
|
|
12
|
+
import type { ChurnMap } from "../types";
|
|
13
|
+
|
|
14
|
+
/** Classificador de arquivos alterados por domínio de review. */
|
|
15
|
+
export class FileClassifier {
|
|
16
|
+
private readonly config: HoundsmanConfig;
|
|
17
|
+
|
|
18
|
+
constructor(config: HoundsmanConfig) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Classifica entradas já parseadas (`git diff --name-status`). */
|
|
23
|
+
classify(
|
|
24
|
+
entries: ReturnType<typeof parseEntries>,
|
|
25
|
+
churn: ChurnMap = {},
|
|
26
|
+
): Classification {
|
|
27
|
+
return new Classification(classifyEntries(entries, this.config, churn));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Executa o diff e classifica os arquivos entre `base` e `head`. */
|
|
31
|
+
classifyRange(base: string, head: string, diff?: DiffSource): Classification {
|
|
32
|
+
return new Classification(classifyRange(base, head, this.config, diff));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Montagem do lote de correção (biblioteca) — superfície OO sobre `fix-batch`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FindingsStateFile } from "../findings-state";
|
|
6
|
+
import { buildFixBatch, type FixBatchOptions, type FixBatchResult } from "../fix-batch";
|
|
7
|
+
|
|
8
|
+
/** Constrói o lote de correção a partir do estado de triagem. */
|
|
9
|
+
export class FixBatchBuilder {
|
|
10
|
+
build(state: FindingsStateFile, options: FixBatchOptions = {}): FixBatchResult {
|
|
11
|
+
return buildFixBatch(state, options);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente do GitHub CLI (`gh`) — biblioteca.
|
|
3
|
+
*
|
|
4
|
+
* Um único runner para todo o motor (`context` e `findings submit`). O `gh` é
|
|
5
|
+
* opcional e falha-aberto: o runner nunca lança, devolve o resultado cru.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type GhResult = { ok: boolean; code: number; stdout: string; stderr: string };
|
|
9
|
+
|
|
10
|
+
/** Executa o `gh` e devolve o resultado cru (sem lançar). */
|
|
11
|
+
export type GhRunner = (args: string[]) => GhResult;
|
|
12
|
+
|
|
13
|
+
/** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
|
|
14
|
+
export const defaultGhRunner: GhRunner = (args) => {
|
|
15
|
+
try {
|
|
16
|
+
const result = Bun.spawnSync(["gh", ...args], {
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
});
|
|
20
|
+
const code = result.exitCode ?? 1;
|
|
21
|
+
return {
|
|
22
|
+
ok: code === 0,
|
|
23
|
+
code,
|
|
24
|
+
stdout: new TextDecoder().decode(result.stdout).trim(),
|
|
25
|
+
stderr: new TextDecoder().decode(result.stderr).trim(),
|
|
26
|
+
};
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
code: 127,
|
|
31
|
+
stdout: "",
|
|
32
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Superfície OO sobre o runner injetável. */
|
|
38
|
+
export class GhClient {
|
|
39
|
+
private readonly runner: GhRunner;
|
|
40
|
+
|
|
41
|
+
constructor(runner: GhRunner = defaultGhRunner) {
|
|
42
|
+
this.runner = runner;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Executa `gh` e devolve o resultado cru. */
|
|
46
|
+
run(args: string[]): GhResult {
|
|
47
|
+
return this.runner(args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `true` se o binário existe no PATH (independe de login). */
|
|
51
|
+
isAvailable(): boolean {
|
|
52
|
+
return Boolean(Bun.which("gh"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acesso ao repositório git (biblioteca) — superfície OO sobre `git-runner`.
|
|
3
|
+
*
|
|
4
|
+
* Centraliza resolução de refs, ancestralidade, leitura por commit e coleta de
|
|
5
|
+
* diffs. Nenhum path/id de consumidor aqui.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type CollectDiffOptions,
|
|
10
|
+
DEFAULT_MAX_PER_FILE_CHARS,
|
|
11
|
+
type DiffSource,
|
|
12
|
+
parseDiffIndex,
|
|
13
|
+
parseNumstat,
|
|
14
|
+
} from "../diff-source";
|
|
15
|
+
import {
|
|
16
|
+
defaultGitRunner,
|
|
17
|
+
type GitResult,
|
|
18
|
+
type GitRunner,
|
|
19
|
+
runGit,
|
|
20
|
+
tryGit,
|
|
21
|
+
} from "../git-runner";
|
|
22
|
+
import type { FileChurn } from "../types";
|
|
23
|
+
|
|
24
|
+
/** Repositório git com runner injetável (testes). */
|
|
25
|
+
export class GitRepository {
|
|
26
|
+
private readonly runner: GitRunner;
|
|
27
|
+
|
|
28
|
+
constructor(runner: GitRunner = defaultGitRunner) {
|
|
29
|
+
this.runner = runner;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Executa git e devolve stdout; lança em falha. */
|
|
33
|
+
run(args: string[]): string {
|
|
34
|
+
return runGit(args, this.runner);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Executa git sem lançar (sondagens). */
|
|
38
|
+
tryRun(args: string[]): GitResult {
|
|
39
|
+
return tryGit(args, this.runner);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** SHA completo de uma ref. */
|
|
43
|
+
resolveSha(ref: string): string {
|
|
44
|
+
return this.run(["rev-parse", ref]).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Branch atual; `null` se indisponível (sem git, detached ou erro). */
|
|
48
|
+
currentBranch(): string | null {
|
|
49
|
+
const result = this.tryRun(["branch", "--show-current"]);
|
|
50
|
+
if (!result.ok) return null;
|
|
51
|
+
const branch = result.stdout.trim();
|
|
52
|
+
return branch.length > 0 ? branch : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `true` se `ancestor` é ancestral de `descendant`. Qualquer outro desfecho
|
|
57
|
+
* (ref desconhecida, exit ≠ 0) devolve `false` — cai no review completo.
|
|
58
|
+
*/
|
|
59
|
+
isAncestor(ancestor: string, descendant: string): boolean {
|
|
60
|
+
return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Conteúdo de um arquivo num commit; `null` se ausente. */
|
|
64
|
+
readFileAt(sha: string, path: string): string | null {
|
|
65
|
+
const result = this.tryRun(["show", `${sha}:${path}`]);
|
|
66
|
+
return result.ok ? result.stdout : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `git diff base...head --name-status` cru. */
|
|
70
|
+
diffNameStatus(base: string, head: string): string {
|
|
71
|
+
return this.run(["diff", `${base}...${head}`, "--name-status"]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Churn por arquivo de um intervalo (`--numstat -z`). */
|
|
75
|
+
diffNumstat(range: string): Map<string, FileChurn> {
|
|
76
|
+
return parseNumstat(this.run(["diff", "--numstat", "-z", range]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Patch por arquivo de um intervalo, opcionalmente restrito a `paths`. */
|
|
80
|
+
diffPatch(
|
|
81
|
+
range: string,
|
|
82
|
+
paths?: readonly string[],
|
|
83
|
+
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
84
|
+
): Map<string, string> {
|
|
85
|
+
const args = ["diff", range];
|
|
86
|
+
if (paths && paths.length > 0) args.push("--", ...paths);
|
|
87
|
+
return parseDiffIndex(this.run(args), maxPerFileChars);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Churn de `base...head` — 1 spawn. */
|
|
91
|
+
collectChurn(base: string, head: string): Map<string, FileChurn> {
|
|
92
|
+
return this.diffNumstat(`${base}...${head}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Patch + churn da PR (`base...head`) — 2 spawns fixos. */
|
|
96
|
+
collectDiff(
|
|
97
|
+
base: string,
|
|
98
|
+
head: string,
|
|
99
|
+
options: CollectDiffOptions = {},
|
|
100
|
+
): DiffSource {
|
|
101
|
+
const range = `${base}...${head}`;
|
|
102
|
+
return {
|
|
103
|
+
byFile: parseDiffIndex(
|
|
104
|
+
this.run(["diff", range]),
|
|
105
|
+
options.maxPerFileChars ?? DEFAULT_MAX_PER_FILE_CHARS,
|
|
106
|
+
),
|
|
107
|
+
churn: this.collectChurn(base, head),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Patch + churn de `from..to` — base do review incremental. */
|
|
112
|
+
collectDiffSince(
|
|
113
|
+
from: string,
|
|
114
|
+
to: string,
|
|
115
|
+
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
116
|
+
): DiffSource {
|
|
117
|
+
const range = `${from}..${to}`;
|
|
118
|
+
return {
|
|
119
|
+
byFile: parseDiffIndex(this.run(["diff", range]), maxPerFileChars),
|
|
120
|
+
churn: this.diffNumstat(range),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acesso a arquivos do projeto (biblioteca) — I/O centralizado e injetável.
|
|
3
|
+
*
|
|
4
|
+
* Caminhos relativos são resolvidos contra o `cwd` do projeto; absolutos passam
|
|
5
|
+
* intactos. Operações que falham devolvem `null`/`false` em vez de lançar, para
|
|
6
|
+
* manter o comportamento fail-open do motor.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
|
|
13
|
+
export type FileReader = (relativePath: string) => string | null;
|
|
14
|
+
|
|
15
|
+
/** Acesso ao filesystem resolvido a partir de um `cwd`. */
|
|
16
|
+
export class ProjectFiles {
|
|
17
|
+
private readonly cwd: string;
|
|
18
|
+
|
|
19
|
+
constructor(cwd: string = process.cwd()) {
|
|
20
|
+
this.cwd = cwd;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve um caminho (relativo → absoluto). */
|
|
24
|
+
resolve(path: string): string {
|
|
25
|
+
return isAbsolute(path) ? path : join(this.cwd, path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Lê UTF-8; `null` se ausente/ilegível. */
|
|
29
|
+
read(path: string): string | null {
|
|
30
|
+
const full = this.resolve(path);
|
|
31
|
+
try {
|
|
32
|
+
return existsSync(full) ? readFileSync(full, "utf8") : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Lê + parseia JSON; `null` se ausente/inválido. */
|
|
39
|
+
readJson<T>(path: string): T | null {
|
|
40
|
+
const raw = this.read(path);
|
|
41
|
+
if (raw === null) return null;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(raw) as T;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Escreve UTF-8, criando o diretório pai. */
|
|
50
|
+
write(path: string, content: string): void {
|
|
51
|
+
const full = this.resolve(path);
|
|
52
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
53
|
+
writeFileSync(full, content);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Escrita atômica (tmp + rename). */
|
|
57
|
+
writeAtomic(path: string, content: string): void {
|
|
58
|
+
const full = this.resolve(path);
|
|
59
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
60
|
+
const tmp = `${full}.${process.pid}.tmp`;
|
|
61
|
+
writeFileSync(tmp, content);
|
|
62
|
+
renameSync(tmp, full);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Cria o diretório (recursivo). */
|
|
66
|
+
ensureDir(path: string): void {
|
|
67
|
+
mkdirSync(this.resolve(path), { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `true` se o caminho existe. */
|
|
71
|
+
exists(path: string): boolean {
|
|
72
|
+
return existsSync(this.resolve(path));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Função de leitura ligada a este `cwd` (compatível com `FileReader`). */
|
|
76
|
+
reader(): FileReader {
|
|
77
|
+
return (relativePath: string) => this.read(relativePath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verificação de findings (biblioteca) — superfície OO sobre `verify.ts`.
|
|
3
|
+
*
|
|
4
|
+
* O tier local (âncora de conteúdo) e o tier Jev continuam em funções puras; a
|
|
5
|
+
* classe concentra a sequência usada pelo comando `findings verify`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
FileReader,
|
|
10
|
+
FindingState,
|
|
11
|
+
FindingsFilter,
|
|
12
|
+
FindingsStateFile,
|
|
13
|
+
} from "../findings-state";
|
|
14
|
+
import {
|
|
15
|
+
applyJev,
|
|
16
|
+
applyResolutions,
|
|
17
|
+
selectVerifiable,
|
|
18
|
+
summarizeVerify,
|
|
19
|
+
type VerifyEntry,
|
|
20
|
+
type VerifySummary,
|
|
21
|
+
verifyLocal,
|
|
22
|
+
} from "../verify";
|
|
23
|
+
|
|
24
|
+
/** Encadeia seleção, tier local, confirmação Jev e persistência. */
|
|
25
|
+
export class Verifier {
|
|
26
|
+
/** Fila de verificação (presentes, sem `ignore`). */
|
|
27
|
+
select(state: FindingsStateFile, filter: FindingsFilter = {}): FindingState[] {
|
|
28
|
+
return selectVerifiable(state, filter);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tier 0 — checagem local por reancoragem de conteúdo. */
|
|
32
|
+
verifyLocal(findings: readonly FindingState[], readFile: FileReader): VerifyEntry[] {
|
|
33
|
+
return verifyLocal(findings, readFile);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Tier 1 — aplica a confirmação do Jev aos casos ambíguos. */
|
|
37
|
+
applyJev(
|
|
38
|
+
entries: VerifyEntry[],
|
|
39
|
+
confidence: Map<string, number>,
|
|
40
|
+
threshold: number,
|
|
41
|
+
): number {
|
|
42
|
+
return applyJev(entries, confidence, threshold);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Contagem por status. */
|
|
46
|
+
summarize(entries: readonly VerifyEntry[]): VerifySummary {
|
|
47
|
+
return summarizeVerify(entries);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Persiste os `resolved` como `done`. */
|
|
51
|
+
applyResolutions(
|
|
52
|
+
state: FindingsStateFile,
|
|
53
|
+
entries: readonly VerifyEntry[],
|
|
54
|
+
now?: string,
|
|
55
|
+
): string[] {
|
|
56
|
+
return applyResolutions(state, entries, now);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/core/verify.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type FindingsStateFile,
|
|
20
20
|
filterFindings,
|
|
21
21
|
} from "./findings-state";
|
|
22
|
+
import { systemClock } from "./services/clock";
|
|
22
23
|
import type { Severity } from "./types";
|
|
23
24
|
|
|
24
25
|
/** Resultado da verificação de um finding. */
|
|
@@ -160,7 +161,7 @@ export function summarizeVerify(entries: readonly VerifyEntry[]): VerifySummary
|
|
|
160
161
|
export function applyResolutions(
|
|
161
162
|
state: FindingsStateFile,
|
|
162
163
|
entries: readonly VerifyEntry[],
|
|
163
|
-
now: string =
|
|
164
|
+
now: string = systemClock(),
|
|
164
165
|
): string[] {
|
|
165
166
|
const changed: string[] = [];
|
|
166
167
|
for (const entry of entries) {
|