@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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apresentação do contexto de um watchdog (biblioteca) — markdown/JSON.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { WatchdogContext } from "../domain/watchdog";
|
|
6
|
+
import {
|
|
7
|
+
type Rule,
|
|
8
|
+
SEVERITY_EMOJI,
|
|
9
|
+
severityRank,
|
|
10
|
+
type WatchdogDefinition,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
/** Opções de apresentação do watchdog. */
|
|
14
|
+
export type WatchdogRenderOptions = {
|
|
15
|
+
/** Inclui as regras. */
|
|
16
|
+
withRules: boolean;
|
|
17
|
+
/** Saída JSON (default: markdown). */
|
|
18
|
+
json: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Renderiza o contexto de um watchdog. */
|
|
22
|
+
export class WatchdogPresenter {
|
|
23
|
+
render(
|
|
24
|
+
context: WatchdogContext,
|
|
25
|
+
definition: WatchdogDefinition,
|
|
26
|
+
options: WatchdogRenderOptions,
|
|
27
|
+
): string {
|
|
28
|
+
return options.json
|
|
29
|
+
? this.serialize(context, definition, options.withRules)
|
|
30
|
+
: this.markdown(context, definition, options.withRules);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Payload serializável (regras incluídas apenas com `withRules`). */
|
|
34
|
+
private serialize(
|
|
35
|
+
context: WatchdogContext,
|
|
36
|
+
definition: WatchdogDefinition,
|
|
37
|
+
withRules: boolean,
|
|
38
|
+
): string {
|
|
39
|
+
const payload = withRules
|
|
40
|
+
? { ...context, rules: definition.rules }
|
|
41
|
+
: { ...context };
|
|
42
|
+
return JSON.stringify(payload, null, 2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lista de itens em bloco `## <título>` (vazio se não houver itens). */
|
|
46
|
+
private section(title: string, items: readonly string[] | undefined): string[] {
|
|
47
|
+
if (!items || items.length === 0) return [];
|
|
48
|
+
return [`## ${title}`, "", ...items.map((item) => `- ${item}`), ""];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Regras com emoji de severidade em bloco `## <título>`. */
|
|
52
|
+
private ruleSection(title: string, rules: readonly Rule[]): string[] {
|
|
53
|
+
return [
|
|
54
|
+
`## ${title}`,
|
|
55
|
+
"",
|
|
56
|
+
...rules.map(
|
|
57
|
+
(rule) =>
|
|
58
|
+
`- ${SEVERITY_EMOJI[rule.severity]} **${rule.severity}** — ${rule.text}`,
|
|
59
|
+
),
|
|
60
|
+
"",
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render markdown legível do contexto do watchdog. */
|
|
65
|
+
private markdown(
|
|
66
|
+
context: WatchdogContext,
|
|
67
|
+
definition: WatchdogDefinition,
|
|
68
|
+
withRules: boolean,
|
|
69
|
+
): string {
|
|
70
|
+
const lines: string[] = [
|
|
71
|
+
`# ${context.name} (\`${context.id}\`)`,
|
|
72
|
+
"",
|
|
73
|
+
`**Descrição:** ${context.description}`,
|
|
74
|
+
`**Escopo:** ${context.scope}`,
|
|
75
|
+
"",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
lines.push(...this.section("Prioridade de leitura", context.reviewFocus));
|
|
79
|
+
lines.push(...this.section("Não carregar", context.exclude));
|
|
80
|
+
lines.push(
|
|
81
|
+
...this.section(
|
|
82
|
+
"Docs de contexto",
|
|
83
|
+
context.contextFiles.map((doc) => `\`${doc}\``),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (withRules) {
|
|
88
|
+
lines.push(
|
|
89
|
+
...this.ruleSection(
|
|
90
|
+
"Regras",
|
|
91
|
+
[...definition.rules].sort(
|
|
92
|
+
(a, b) => severityRank(a.severity) - severityRank(b.severity),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return lines.join("\n").trimEnd();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -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,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removido: o layout de artefatos do projeto deu lugar ao staging global por
|
|
3
|
+
* review (`ReviewWorkArea` em `core/storage/work-area.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Mantido apenas para não quebrar imports antigos de tipos.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { ContextPaths } from "./storage/work-area";
|
package/src/core/run-status.ts
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Status do run (biblioteca) —
|
|
2
|
+
* Status do run (biblioteca) — colunas da review no storage.
|
|
3
3
|
*
|
|
4
4
|
* Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
|
|
5
5
|
* (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
|
|
6
6
|
*
|
|
7
|
-
* Toda ação que muda o estado faz um **ping** no `updatedAt
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Toda ação que muda o estado faz um **ping** no `updatedAt`. Timeout é
|
|
8
|
+
* **display-only** (a UI compara `updatedAt` com o agora) — não é um status
|
|
9
|
+
* persistido.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
14
12
|
import type { FindingsStateFile } from "./findings-state";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
13
|
+
import { systemClock } from "./services/clock";
|
|
14
|
+
import type { ReviewRecord, ReviewStatus, StorageProvider } from "./storage/types";
|
|
17
15
|
|
|
18
16
|
/** Estados de execução de uma run (persistidos). */
|
|
19
|
-
export type RunStatus =
|
|
17
|
+
export type RunStatus = ReviewStatus;
|
|
20
18
|
|
|
21
19
|
/** Valores canônicos de status (ordem de exibição/lifecycle). */
|
|
22
20
|
export const RUN_STATUSES: readonly RunStatus[] = [
|
|
@@ -26,62 +24,34 @@ export const RUN_STATUSES: readonly RunStatus[] = [
|
|
|
26
24
|
"fix_done",
|
|
27
25
|
];
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
branch: string;
|
|
36
|
-
status: RunStatus;
|
|
37
|
-
/** Início do ciclo de revisão atual (reiniciado a cada `prepare`). */
|
|
38
|
-
startedAt: string;
|
|
39
|
-
/** Última ação importante (ping). */
|
|
40
|
-
updatedAt: string;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/** Caminho do `run.json` dentro da pasta da run. */
|
|
44
|
-
export function runStatusPath(runDir: string): string {
|
|
45
|
-
return join(runDir, RUN_STATUS_FILE);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Lê o `run.json` da run (null se ausente/inválido). */
|
|
49
|
-
export function readRunStatus(runDir: string): RunStatusFile | null {
|
|
50
|
-
const path = runStatusPath(runDir);
|
|
51
|
-
if (!existsSync(path)) return null;
|
|
52
|
-
try {
|
|
53
|
-
return JSON.parse(readFileSync(path, "utf8")) as RunStatusFile;
|
|
54
|
-
} catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
27
|
+
/** Lê o status atual (null se a review não existir). */
|
|
28
|
+
export function readRunStatus(
|
|
29
|
+
storage: StorageProvider,
|
|
30
|
+
reviewId: string,
|
|
31
|
+
): ReviewRecord | null {
|
|
32
|
+
return storage.getReview(reviewId);
|
|
57
33
|
}
|
|
58
34
|
|
|
59
35
|
export type WriteRunStatusOptions = {
|
|
60
|
-
branch: string;
|
|
61
36
|
status: RunStatus;
|
|
62
37
|
/** Reinicia o `startedAt` — usado no início de uma nova revisão. */
|
|
63
38
|
resetStartedAt?: boolean;
|
|
64
39
|
now?: string;
|
|
65
40
|
};
|
|
66
41
|
|
|
67
|
-
/** Grava/atualiza o
|
|
42
|
+
/** Grava/atualiza o status da review (ping no `updatedAt`). */
|
|
68
43
|
export function writeRunStatus(
|
|
69
|
-
|
|
44
|
+
storage: StorageProvider,
|
|
45
|
+
reviewId: string,
|
|
70
46
|
options: WriteRunStatusOptions,
|
|
71
|
-
):
|
|
72
|
-
const
|
|
73
|
-
const previous = readRunStatus(runDir);
|
|
74
|
-
const file: RunStatusFile = {
|
|
75
|
-
version: RUN_STATUS_VERSION,
|
|
76
|
-
slug: sanitizeBranch(options.branch),
|
|
77
|
-
branch: options.branch,
|
|
47
|
+
): ReviewRecord {
|
|
48
|
+
const updated = storage.updateReview(reviewId, {
|
|
78
49
|
status: options.status,
|
|
79
|
-
|
|
80
|
-
updatedAt: now,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return file;
|
|
50
|
+
resetStartedAt: options.resetStartedAt,
|
|
51
|
+
updatedAt: options.now ?? systemClock(),
|
|
52
|
+
});
|
|
53
|
+
if (!updated) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
54
|
+
return updated;
|
|
85
55
|
}
|
|
86
56
|
|
|
87
57
|
/**
|
|
@@ -110,14 +80,14 @@ export function statusAfterFindingsChange(
|
|
|
110
80
|
return previous ?? "review_done";
|
|
111
81
|
}
|
|
112
82
|
|
|
113
|
-
/** Ping: deriva o status da mudança nos findings e grava
|
|
83
|
+
/** Ping: deriva o status da mudança nos findings e grava na review. */
|
|
114
84
|
export function pingFindingsStatus(
|
|
115
|
-
|
|
85
|
+
storage: StorageProvider,
|
|
86
|
+
reviewId: string,
|
|
116
87
|
state: FindingsStateFile,
|
|
117
|
-
):
|
|
118
|
-
const previous =
|
|
119
|
-
return writeRunStatus(
|
|
120
|
-
branch: state.branch,
|
|
88
|
+
): ReviewRecord {
|
|
89
|
+
const previous = storage.getReview(reviewId)?.status ?? null;
|
|
90
|
+
return writeRunStatus(storage, reviewId, {
|
|
121
91
|
status: statusAfterFindingsChange(previous, state),
|
|
122
92
|
});
|
|
123
93
|
}
|
|
@@ -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,139 @@
|
|
|
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
|
+
/** Raiz do repositório; `null` fora de um repo git. */
|
|
56
|
+
rootPath(): string | null {
|
|
57
|
+
const result = this.tryRun(["rev-parse", "--show-toplevel"]);
|
|
58
|
+
if (!result.ok) return null;
|
|
59
|
+
const root = result.stdout.trim();
|
|
60
|
+
return root.length > 0 ? root : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** URL de um remote (default `origin`); `null` se ausente. */
|
|
64
|
+
remoteUrl(name = "origin"): string | null {
|
|
65
|
+
const result = this.tryRun(["remote", "get-url", name]);
|
|
66
|
+
if (!result.ok) return null;
|
|
67
|
+
const url = result.stdout.trim();
|
|
68
|
+
return url.length > 0 ? url : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `true` se `ancestor` é ancestral de `descendant`. Qualquer outro desfecho
|
|
73
|
+
* (ref desconhecida, exit ≠ 0) devolve `false` — cai no review completo.
|
|
74
|
+
*/
|
|
75
|
+
isAncestor(ancestor: string, descendant: string): boolean {
|
|
76
|
+
return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Conteúdo de um arquivo num commit; `null` se ausente. */
|
|
80
|
+
readFileAt(sha: string, path: string): string | null {
|
|
81
|
+
const result = this.tryRun(["show", `${sha}:${path}`]);
|
|
82
|
+
return result.ok ? result.stdout : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `git diff base...head --name-status` cru. */
|
|
86
|
+
diffNameStatus(base: string, head: string): string {
|
|
87
|
+
return this.run(["diff", `${base}...${head}`, "--name-status"]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Churn por arquivo de um intervalo (`--numstat -z`). */
|
|
91
|
+
diffNumstat(range: string): Map<string, FileChurn> {
|
|
92
|
+
return parseNumstat(this.run(["diff", "--numstat", "-z", range]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Patch por arquivo de um intervalo, opcionalmente restrito a `paths`. */
|
|
96
|
+
diffPatch(
|
|
97
|
+
range: string,
|
|
98
|
+
paths?: readonly string[],
|
|
99
|
+
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
100
|
+
): Map<string, string> {
|
|
101
|
+
const args = ["diff", range];
|
|
102
|
+
if (paths && paths.length > 0) args.push("--", ...paths);
|
|
103
|
+
return parseDiffIndex(this.run(args), maxPerFileChars);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Churn de `base...head` — 1 spawn. */
|
|
107
|
+
collectChurn(base: string, head: string): Map<string, FileChurn> {
|
|
108
|
+
return this.diffNumstat(`${base}...${head}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Patch + churn da PR (`base...head`) — 2 spawns fixos. */
|
|
112
|
+
collectDiff(
|
|
113
|
+
base: string,
|
|
114
|
+
head: string,
|
|
115
|
+
options: CollectDiffOptions = {},
|
|
116
|
+
): DiffSource {
|
|
117
|
+
const range = `${base}...${head}`;
|
|
118
|
+
return {
|
|
119
|
+
byFile: parseDiffIndex(
|
|
120
|
+
this.run(["diff", range]),
|
|
121
|
+
options.maxPerFileChars ?? DEFAULT_MAX_PER_FILE_CHARS,
|
|
122
|
+
),
|
|
123
|
+
churn: this.collectChurn(base, head),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Patch + churn de `from..to` — base do review incremental. */
|
|
128
|
+
collectDiffSince(
|
|
129
|
+
from: string,
|
|
130
|
+
to: string,
|
|
131
|
+
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
132
|
+
): DiffSource {
|
|
133
|
+
const range = `${from}..${to}`;
|
|
134
|
+
return {
|
|
135
|
+
byFile: parseDiffIndex(this.run(["diff", range]), maxPerFileChars),
|
|
136
|
+
churn: this.diffNumstat(range),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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 {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
|
|
20
|
+
export type FileReader = (relativePath: string) => string | null;
|
|
21
|
+
|
|
22
|
+
/** Acesso ao filesystem resolvido a partir de um `cwd`. */
|
|
23
|
+
export class ProjectFiles {
|
|
24
|
+
private readonly cwd: string;
|
|
25
|
+
|
|
26
|
+
constructor(cwd: string = process.cwd()) {
|
|
27
|
+
this.cwd = cwd;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve um caminho (relativo → absoluto). */
|
|
31
|
+
resolve(path: string): string {
|
|
32
|
+
return isAbsolute(path) ? path : join(this.cwd, path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Lê UTF-8; `null` se ausente/ilegível. */
|
|
36
|
+
read(path: string): string | null {
|
|
37
|
+
const full = this.resolve(path);
|
|
38
|
+
try {
|
|
39
|
+
return existsSync(full) ? readFileSync(full, "utf8") : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lê + parseia JSON; `null` se ausente/inválido. */
|
|
46
|
+
readJson<T>(path: string): T | null {
|
|
47
|
+
const raw = this.read(path);
|
|
48
|
+
if (raw === null) return null;
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(raw) as T;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Escreve UTF-8, criando o diretório pai. */
|
|
57
|
+
write(path: string, content: string): void {
|
|
58
|
+
const full = this.resolve(path);
|
|
59
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
60
|
+
writeFileSync(full, content);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Escrita atômica (tmp + rename). */
|
|
64
|
+
writeAtomic(path: string, content: string): void {
|
|
65
|
+
const full = this.resolve(path);
|
|
66
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
67
|
+
const tmp = `${full}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(tmp, content);
|
|
69
|
+
renameSync(tmp, full);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Cria o diretório (recursivo). */
|
|
73
|
+
ensureDir(path: string): void {
|
|
74
|
+
mkdirSync(this.resolve(path), { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Remove um diretório (recursivo, best-effort). */
|
|
78
|
+
removeDir(path: string): void {
|
|
79
|
+
rmSync(this.resolve(path), { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `true` se o caminho existe. */
|
|
83
|
+
exists(path: string): boolean {
|
|
84
|
+
return existsSync(this.resolve(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Função de leitura ligada a este `cwd` (compatível com `FileReader`). */
|
|
88
|
+
reader(): FileReader {
|
|
89
|
+
return (relativePath: string) => this.read(relativePath);
|
|
90
|
+
}
|
|
91
|
+
}
|