@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,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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fábrica do provider de persistência (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Default: SQLite no diretório global. `storage.provider: "file"` mantém o
|
|
5
|
+
* backend em arquivos sob o mesmo home — útil para debug ou rollback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HoundsmanConfig } from "../config";
|
|
9
|
+
import { FileStorageProvider } from "./file/file-storage";
|
|
10
|
+
import { databasePath, fileRoot } from "./paths";
|
|
11
|
+
import { SQLiteStorageProvider } from "./sqlite/sqlite-storage";
|
|
12
|
+
import type { StorageProvider } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Cria o provider configurado pelo projeto. */
|
|
15
|
+
export function createStorage(config: HoundsmanConfig): StorageProvider {
|
|
16
|
+
return config.storage?.provider === "file"
|
|
17
|
+
? new FileStorageProvider(fileRoot(config))
|
|
18
|
+
: new SQLiteStorageProvider(databasePath(config));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Provider SQLite em memória (testes). */
|
|
22
|
+
export function createMemoryStorage(): StorageProvider {
|
|
23
|
+
return new SQLiteStorageProvider(":memory:");
|
|
24
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider de arquivos do houndsman (biblioteca) — fallback do SQLite.
|
|
3
|
+
*
|
|
4
|
+
* Mantém o formato antigo (JSON por review), mas sob o diretório global do
|
|
5
|
+
* houndsman. Serve para debug/rollback: trocar `storage.provider` para `"file"`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { FINDINGS_STATE_VERSION, type FindingsStateFile } from "../../findings-state";
|
|
12
|
+
import { systemClock } from "../../services/clock";
|
|
13
|
+
import { ProjectFiles } from "../../services/project-files";
|
|
14
|
+
import type {
|
|
15
|
+
ArtifactRecord,
|
|
16
|
+
CreateReviewInput,
|
|
17
|
+
ReviewPatch,
|
|
18
|
+
ReviewRecord,
|
|
19
|
+
StorageProvider,
|
|
20
|
+
WriteArtifactInput,
|
|
21
|
+
} from "../types";
|
|
22
|
+
|
|
23
|
+
const REVIEW_FILE = "review.json";
|
|
24
|
+
const FINDINGS_FILE = "findings.json";
|
|
25
|
+
const ARTIFACTS_FILE = "artifacts.json";
|
|
26
|
+
|
|
27
|
+
/** Provider em arquivos sob uma raiz global. */
|
|
28
|
+
export class FileStorageProvider implements StorageProvider {
|
|
29
|
+
readonly kind = "file" as const;
|
|
30
|
+
private readonly root: string;
|
|
31
|
+
private readonly files = new ProjectFiles();
|
|
32
|
+
|
|
33
|
+
constructor(root: string) {
|
|
34
|
+
this.root = root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
// Reviews
|
|
39
|
+
// -----------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
getOrCreateReview(input: CreateReviewInput): ReviewRecord {
|
|
42
|
+
const existing = this.findReview(input.repo.key, input.branch);
|
|
43
|
+
if (existing) {
|
|
44
|
+
if (
|
|
45
|
+
existing.repoRoot !== input.repo.root ||
|
|
46
|
+
existing.repoRemote !== input.repo.remote
|
|
47
|
+
) {
|
|
48
|
+
return this.writeReview({
|
|
49
|
+
...existing,
|
|
50
|
+
repoRoot: input.repo.root,
|
|
51
|
+
repoRemote: input.repo.remote,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const now = input.now ?? systemClock();
|
|
58
|
+
const record: ReviewRecord = {
|
|
59
|
+
id: randomUUID(),
|
|
60
|
+
repoKey: input.repo.key,
|
|
61
|
+
repoRoot: input.repo.root,
|
|
62
|
+
repoRemote: input.repo.remote,
|
|
63
|
+
branch: input.branch,
|
|
64
|
+
slug: input.slug,
|
|
65
|
+
base: input.base,
|
|
66
|
+
head: input.head,
|
|
67
|
+
status: null,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
};
|
|
71
|
+
return this.writeReview(record);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
findReview(repoKey: string, branch: string): ReviewRecord | null {
|
|
75
|
+
return (
|
|
76
|
+
this.readReviews(repoKey).find((review) => review.branch === branch) ?? null
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getReview(id: string): ReviewRecord | null {
|
|
81
|
+
return this.readReviews().find((review) => review.id === id) ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
listReviews(repoKey?: string): ReviewRecord[] {
|
|
85
|
+
return this.readReviews(repoKey).sort((a, b) =>
|
|
86
|
+
b.updatedAt.localeCompare(a.updatedAt),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
updateReview(id: string, patch: ReviewPatch): ReviewRecord | null {
|
|
91
|
+
const review = this.getReview(id);
|
|
92
|
+
if (!review) return null;
|
|
93
|
+
const now = patch.updatedAt ?? systemClock();
|
|
94
|
+
const updated: ReviewRecord = { ...review };
|
|
95
|
+
if (patch.branch !== undefined) updated.branch = patch.branch;
|
|
96
|
+
if (patch.slug !== undefined) updated.slug = patch.slug;
|
|
97
|
+
if (patch.base !== undefined) updated.base = patch.base;
|
|
98
|
+
if (patch.head !== undefined) updated.head = patch.head;
|
|
99
|
+
if (patch.headSha !== undefined) updated.headSha = patch.headSha;
|
|
100
|
+
if (patch.reviewedSha !== undefined) updated.reviewedSha = patch.reviewedSha;
|
|
101
|
+
if (patch.reviewedAt !== undefined) updated.reviewedAt = patch.reviewedAt;
|
|
102
|
+
if (patch.status !== undefined) updated.status = patch.status;
|
|
103
|
+
if (patch.resetStartedAt) updated.startedAt = now;
|
|
104
|
+
else if (patch.startedAt !== undefined) updated.startedAt = patch.startedAt;
|
|
105
|
+
updated.updatedAt = patch.updatedAt ?? now;
|
|
106
|
+
return this.writeReview(updated);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
deleteReview(id: string): void {
|
|
110
|
+
const review = this.getReview(id);
|
|
111
|
+
if (!review) return;
|
|
112
|
+
this.files.removeDir(this.reviewDir(review));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
// Findings
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
readFindings(reviewId: string): FindingsStateFile | null {
|
|
120
|
+
const review = this.getReview(reviewId);
|
|
121
|
+
if (!review) return null;
|
|
122
|
+
const raw = this.files.readJson<FindingsStateFile>(
|
|
123
|
+
join(this.reviewDir(review), FINDINGS_FILE),
|
|
124
|
+
);
|
|
125
|
+
if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
126
|
+
return raw;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
writeFindings(reviewId: string, state: FindingsStateFile): void {
|
|
130
|
+
const review = this.getReview(reviewId);
|
|
131
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
132
|
+
this.writeJson(join(this.reviewDir(review), FINDINGS_FILE), state);
|
|
133
|
+
this.writeReview({
|
|
134
|
+
...review,
|
|
135
|
+
reviewedSha: state.reviewed?.headSha,
|
|
136
|
+
reviewedAt: state.reviewed?.at,
|
|
137
|
+
updatedAt: state.updatedAt || systemClock(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
// Artefatos
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
readArtifact(reviewId: string, name: string): ArtifactRecord | null {
|
|
146
|
+
const review = this.getReview(reviewId);
|
|
147
|
+
if (!review) return null;
|
|
148
|
+
return this.readArtifacts(review)[name] ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
writeArtifact(reviewId: string, input: WriteArtifactInput): ArtifactRecord {
|
|
152
|
+
const review = this.getReview(reviewId);
|
|
153
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
154
|
+
const index = this.readArtifacts(review);
|
|
155
|
+
const now = systemClock();
|
|
156
|
+
const existing = index[input.name];
|
|
157
|
+
const contentType =
|
|
158
|
+
input.contentType ??
|
|
159
|
+
(input.name.toLowerCase().endsWith(".json")
|
|
160
|
+
? "application/json; charset=utf-8"
|
|
161
|
+
: "text/markdown; charset=utf-8");
|
|
162
|
+
const record: ArtifactRecord = {
|
|
163
|
+
id: existing?.id ?? randomUUID(),
|
|
164
|
+
reviewId,
|
|
165
|
+
name: input.name,
|
|
166
|
+
kind: input.kind,
|
|
167
|
+
content: input.content,
|
|
168
|
+
contentType,
|
|
169
|
+
createdAt: existing?.createdAt ?? now,
|
|
170
|
+
updatedAt: now,
|
|
171
|
+
};
|
|
172
|
+
index[input.name] = record;
|
|
173
|
+
this.writeJson(join(this.reviewDir(review), ARTIFACTS_FILE), index);
|
|
174
|
+
return record;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
listArtifacts(reviewId: string): ArtifactRecord[] {
|
|
178
|
+
const review = this.getReview(reviewId);
|
|
179
|
+
if (!review) return [];
|
|
180
|
+
return Object.values(this.readArtifacts(review)).sort((a, b) =>
|
|
181
|
+
a.name.localeCompare(b.name),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
deleteArtifact(reviewId: string, name: string): void {
|
|
186
|
+
const review = this.getReview(reviewId);
|
|
187
|
+
if (!review) return;
|
|
188
|
+
const index = this.readArtifacts(review);
|
|
189
|
+
if (!index[name]) return;
|
|
190
|
+
delete index[name];
|
|
191
|
+
this.writeJson(join(this.reviewDir(review), ARTIFACTS_FILE), index);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// Manutenção / ciclo de vida
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
maintain(): void {
|
|
199
|
+
// Nada a compactar: arquivos JSON são reescritos por inteiro.
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
location(reviewId: string): string {
|
|
203
|
+
const review = this.getReview(reviewId);
|
|
204
|
+
return review
|
|
205
|
+
? `file:${join(this.reviewDir(review), FINDINGS_FILE)}#${reviewId}`
|
|
206
|
+
: `file:${this.root}#${reviewId}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
close(): void {
|
|
210
|
+
// Sem recurso aberto.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -----------------------------------------------------------------------
|
|
214
|
+
// Internos
|
|
215
|
+
// -----------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
private repoDir(repoKey: string): string {
|
|
218
|
+
const hash = createHash("sha256").update(repoKey).digest("hex").slice(0, 16);
|
|
219
|
+
return join(this.root, hash);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private reviewDir(review: ReviewRecord): string {
|
|
223
|
+
return join(this.repoDir(review.repoKey), review.slug);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private writeReview(record: ReviewRecord): ReviewRecord {
|
|
227
|
+
this.writeJson(join(this.reviewDir(record), REVIEW_FILE), record);
|
|
228
|
+
return record;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private readReviews(repoKey?: string): ReviewRecord[] {
|
|
232
|
+
const roots = repoKey ? [this.repoDir(repoKey)] : this.subdirectories(this.root);
|
|
233
|
+
const reviews: ReviewRecord[] = [];
|
|
234
|
+
for (const repoDir of roots) {
|
|
235
|
+
for (const slugDir of this.subdirectories(repoDir)) {
|
|
236
|
+
const review = this.files.readJson<ReviewRecord>(
|
|
237
|
+
join(slugDir, REVIEW_FILE),
|
|
238
|
+
);
|
|
239
|
+
if (review && (!repoKey || review.repoKey === repoKey)) {
|
|
240
|
+
reviews.push(review);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return reviews;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private readArtifacts(review: ReviewRecord): Record<string, ArtifactRecord> {
|
|
248
|
+
return (
|
|
249
|
+
this.files.readJson<Record<string, ArtifactRecord>>(
|
|
250
|
+
join(this.reviewDir(review), ARTIFACTS_FILE),
|
|
251
|
+
) ?? {}
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private writeJson(path: string, value: unknown): void {
|
|
256
|
+
this.files.writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private subdirectories(dir: string): string[] {
|
|
260
|
+
if (!existsSync(dir)) return [];
|
|
261
|
+
try {
|
|
262
|
+
return readdirSync(dir)
|
|
263
|
+
.map((name) => join(dir, name))
|
|
264
|
+
.filter((path) => statSync(path).isDirectory());
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localização global do houndsman (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* O banco e o staging vivem no diretório de dados do usuário — nunca dentro do
|
|
5
|
+
* projeto nem da instalação npm. `HOUNDSMAN_HOME` sobrescreve tudo (útil em
|
|
6
|
+
* testes e para manter múltiplos ambientes).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import type { HoundsmanConfig } from "../config";
|
|
12
|
+
|
|
13
|
+
export const HOUNDSMAN_HOME_ENV = "HOUNDSMAN_HOME";
|
|
14
|
+
|
|
15
|
+
/** Diretório-base global do houndsman. */
|
|
16
|
+
export function houndsmanHome(): string {
|
|
17
|
+
const override = process.env[HOUNDSMAN_HOME_ENV];
|
|
18
|
+
if (override && override.trim().length > 0) return resolve(override);
|
|
19
|
+
|
|
20
|
+
const home = homedir();
|
|
21
|
+
if (process.platform === "win32") {
|
|
22
|
+
const local = process.env.LOCALAPPDATA;
|
|
23
|
+
return join(
|
|
24
|
+
local && local.length > 0 ? local : join(home, "AppData", "Local"),
|
|
25
|
+
"houndsman",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (process.platform === "darwin") {
|
|
29
|
+
return join(home, "Library", "Application Support", "houndsman");
|
|
30
|
+
}
|
|
31
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
32
|
+
return join(xdg && xdg.length > 0 ? xdg : join(home, ".local", "share"), "houndsman");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Caminho do banco SQLite (config `storage.path` ou `<home>/houndsman.db`). */
|
|
36
|
+
export function databasePath(config: HoundsmanConfig): string {
|
|
37
|
+
const configured = config.storage?.path;
|
|
38
|
+
if (configured && configured.trim().length > 0) return resolve(configured);
|
|
39
|
+
return join(houndsmanHome(), "houndsman.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Raiz das runs do provider de arquivos (config `storage.path` ou `<home>/runs`). */
|
|
43
|
+
export function fileRoot(config: HoundsmanConfig): string {
|
|
44
|
+
const configured = config.storage?.path;
|
|
45
|
+
if (configured && configured.trim().length > 0) return resolve(configured);
|
|
46
|
+
return join(houndsmanHome(), "runs");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Raiz do staging por review (briefs/findings/contexto dos subagentes). */
|
|
50
|
+
export function workRoot(): string {
|
|
51
|
+
return join(houndsmanHome(), "work");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Referência legível do storage configurado (diagnóstico). */
|
|
55
|
+
export function storageLocation(config: HoundsmanConfig): string {
|
|
56
|
+
return config.storage?.provider === "file"
|
|
57
|
+
? `file:${fileRoot(config)}`
|
|
58
|
+
: `sqlite:${databasePath(config)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Diretório de staging de uma review. */
|
|
62
|
+
export function workDir(reviewId: string): string {
|
|
63
|
+
return join(workRoot(), reviewId);
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identidade do repositório (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* A UI e o CLI escopam runs por repositório. Preferimos o remote `origin`
|
|
5
|
+
* normalizado (`github.com/org/repo`) porque a pasta local pode ser movida;
|
|
6
|
+
* sem remote, caímos no caminho absoluto da raiz git.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { GitRepository } from "../services/git-repository";
|
|
11
|
+
import type { RepoIdentity } from "./types";
|
|
12
|
+
|
|
13
|
+
/** Normaliza um remote para `host/owner/repo` (sem protocolo, credencial ou `.git`). */
|
|
14
|
+
export function normalizeRemote(raw: string): string | null {
|
|
15
|
+
const value = raw.trim().replace(/\/+$/, "");
|
|
16
|
+
if (value.length === 0) return null;
|
|
17
|
+
|
|
18
|
+
// SCP-like: `git@github.com:owner/repo.git`.
|
|
19
|
+
if (!value.includes("://")) {
|
|
20
|
+
const scp = value.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
21
|
+
if (scp) return `${scp[1].toLowerCase()}/${stripGitSuffix(scp[2])}`;
|
|
22
|
+
// Caminho local — não é um remote utilizável para identidade.
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(value);
|
|
28
|
+
const host = url.hostname.toLowerCase();
|
|
29
|
+
const path = stripGitSuffix(url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
30
|
+
return path.length > 0 ? `${host}/${path}` : host;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripGitSuffix(value: string): string {
|
|
37
|
+
return value.replace(/\.git$/i, "").replace(/^\/+/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normaliza a raiz local para comparação (Windows é case-insensitive). */
|
|
41
|
+
function normalizeRoot(root: string): string {
|
|
42
|
+
const normalized = resolve(root).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
43
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Monta a identidade a partir do remote cru e da raiz do repositório. */
|
|
47
|
+
export function buildRepoIdentity(
|
|
48
|
+
remote: string | null | undefined,
|
|
49
|
+
root: string,
|
|
50
|
+
): RepoIdentity {
|
|
51
|
+
const normalized = remote ? normalizeRemote(remote) : null;
|
|
52
|
+
const absoluteRoot = resolve(root);
|
|
53
|
+
if (normalized) {
|
|
54
|
+
return {
|
|
55
|
+
key: `remote:${normalized}`,
|
|
56
|
+
root: absoluteRoot,
|
|
57
|
+
remote: remote ?? undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { key: `path:${normalizeRoot(absoluteRoot)}`, root: absoluteRoot };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve a identidade do repositório a partir do cwd (nunca lança). */
|
|
64
|
+
export function resolveRepoIdentity(
|
|
65
|
+
git: GitRepository = new GitRepository(),
|
|
66
|
+
cwd: string = process.cwd(),
|
|
67
|
+
): RepoIdentity {
|
|
68
|
+
const root = git.rootPath() ?? resolve(cwd);
|
|
69
|
+
return buildRepoIdentity(git.remoteUrl(), root);
|
|
70
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapeamento row ↔ domínio do storage SQLite.
|
|
3
|
+
*
|
|
4
|
+
* O banco guarda `short_id` (id curto exibido no CLI) e um `id` UUID como PK.
|
|
5
|
+
* O domínio continua usando `FindingState.id` = short id.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FindingAnchor } from "../../finding-anchor";
|
|
9
|
+
import type {
|
|
10
|
+
FindingDecision,
|
|
11
|
+
FindingExecState,
|
|
12
|
+
FindingHistoryEntry,
|
|
13
|
+
FindingState,
|
|
14
|
+
GitHubLink,
|
|
15
|
+
} from "../../findings-state";
|
|
16
|
+
import type { Severity } from "../../types";
|
|
17
|
+
import type { ArtifactKind, ArtifactRecord, ReviewRecord } from "../types";
|
|
18
|
+
|
|
19
|
+
export type ReviewRow = {
|
|
20
|
+
id: string;
|
|
21
|
+
repo_key: string;
|
|
22
|
+
repo_root: string;
|
|
23
|
+
repo_remote: string | null;
|
|
24
|
+
branch: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
base: string | null;
|
|
27
|
+
head: string | null;
|
|
28
|
+
head_sha: string | null;
|
|
29
|
+
reviewed_sha: string | null;
|
|
30
|
+
reviewed_at: string | null;
|
|
31
|
+
status: string | null;
|
|
32
|
+
started_at: string | null;
|
|
33
|
+
created_at: string;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type FindingRow = {
|
|
38
|
+
id: string;
|
|
39
|
+
review_id: string;
|
|
40
|
+
short_id: string;
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
watchdog_ids: string;
|
|
43
|
+
rule: string;
|
|
44
|
+
severity: string;
|
|
45
|
+
file: string;
|
|
46
|
+
line: number | null;
|
|
47
|
+
message: string;
|
|
48
|
+
action: string;
|
|
49
|
+
evidence: string | null;
|
|
50
|
+
anchor: string | null;
|
|
51
|
+
found_at_sha: string | null;
|
|
52
|
+
count: number;
|
|
53
|
+
present: number;
|
|
54
|
+
decision: string;
|
|
55
|
+
comment: string | null;
|
|
56
|
+
exec_state: string;
|
|
57
|
+
github: string | null;
|
|
58
|
+
created_at: string;
|
|
59
|
+
updated_at: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type HistoryRow = {
|
|
63
|
+
id: string;
|
|
64
|
+
finding_id: string;
|
|
65
|
+
at: string;
|
|
66
|
+
decision: string;
|
|
67
|
+
exec_state: string;
|
|
68
|
+
comment: string | null;
|
|
69
|
+
by: string | null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ArtifactRow = {
|
|
73
|
+
id: string;
|
|
74
|
+
review_id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
kind: string;
|
|
77
|
+
content: string;
|
|
78
|
+
content_type: string;
|
|
79
|
+
created_at: string;
|
|
80
|
+
updated_at: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Converte a row de review no DTO do contrato. */
|
|
84
|
+
export function reviewFromRow(row: ReviewRow): ReviewRecord {
|
|
85
|
+
return {
|
|
86
|
+
id: row.id,
|
|
87
|
+
repoKey: row.repo_key,
|
|
88
|
+
repoRoot: row.repo_root,
|
|
89
|
+
repoRemote: row.repo_remote ?? undefined,
|
|
90
|
+
branch: row.branch,
|
|
91
|
+
slug: row.slug,
|
|
92
|
+
base: row.base ?? undefined,
|
|
93
|
+
head: row.head ?? undefined,
|
|
94
|
+
headSha: row.head_sha ?? undefined,
|
|
95
|
+
reviewedSha: row.reviewed_sha ?? undefined,
|
|
96
|
+
reviewedAt: row.reviewed_at ?? undefined,
|
|
97
|
+
status: (row.status as ReviewRecord["status"]) ?? null,
|
|
98
|
+
startedAt: row.started_at ?? undefined,
|
|
99
|
+
createdAt: row.created_at,
|
|
100
|
+
updatedAt: row.updated_at,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseJson<T>(raw: string | null): T | undefined {
|
|
105
|
+
if (!raw) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw) as T;
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Converte a row de finding (+ histórico) no DTO do domínio. */
|
|
114
|
+
export function findingFromRow(row: FindingRow, history: HistoryRow[]): FindingState {
|
|
115
|
+
return {
|
|
116
|
+
id: row.short_id,
|
|
117
|
+
fingerprint: row.fingerprint,
|
|
118
|
+
watchdogIds: parseJson<string[]>(row.watchdog_ids) ?? [],
|
|
119
|
+
rule: row.rule,
|
|
120
|
+
severity: row.severity as Severity,
|
|
121
|
+
file: row.file,
|
|
122
|
+
line: row.line === null ? null : Number(row.line),
|
|
123
|
+
message: row.message,
|
|
124
|
+
action: row.action,
|
|
125
|
+
evidence: row.evidence ?? undefined,
|
|
126
|
+
anchor: parseJson<FindingAnchor>(row.anchor) ?? null,
|
|
127
|
+
foundAtSha: row.found_at_sha ?? undefined,
|
|
128
|
+
count: Number(row.count),
|
|
129
|
+
present: row.present === 1,
|
|
130
|
+
decision: row.decision as FindingDecision,
|
|
131
|
+
comment: row.comment ?? undefined,
|
|
132
|
+
execState: row.exec_state as FindingExecState,
|
|
133
|
+
createdAt: row.created_at,
|
|
134
|
+
updatedAt: row.updated_at,
|
|
135
|
+
history: history.map((entry) => ({
|
|
136
|
+
at: entry.at,
|
|
137
|
+
decision: entry.decision as FindingDecision,
|
|
138
|
+
execState: entry.exec_state as FindingExecState,
|
|
139
|
+
comment: entry.comment ?? undefined,
|
|
140
|
+
by: entry.by ?? undefined,
|
|
141
|
+
})),
|
|
142
|
+
github: parseJson<GitHubLink>(row.github),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Converte a row de artefato no DTO do contrato. */
|
|
147
|
+
export function artifactFromRow(row: ArtifactRow): ArtifactRecord {
|
|
148
|
+
return {
|
|
149
|
+
id: row.id,
|
|
150
|
+
reviewId: row.review_id,
|
|
151
|
+
name: row.name,
|
|
152
|
+
kind: row.kind as ArtifactKind,
|
|
153
|
+
content: row.content,
|
|
154
|
+
contentType: row.content_type,
|
|
155
|
+
createdAt: row.created_at,
|
|
156
|
+
updatedAt: row.updated_at,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type { FindingHistoryEntry };
|