@payconductor/houndsman 0.3.0 → 0.5.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 +25 -39
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +8 -3
- package/src/cli/index.ts +5 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +216 -125
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/commands/status.ts +63 -0
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +53 -75
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +271 -134
- package/src/core/paths.ts +26 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +59 -50
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +271 -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 +162 -0
- package/src/core/storage/sqlite/schema.ts +116 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
- package/src/core/storage/types.ts +152 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +144 -23
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +50 -15
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +181 -0
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +58 -10
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/utils/code-highlight.ts +422 -0
- package/src/server/client/utils/constants.ts +226 -0
- package/src/server/client/utils/json-highlight.ts +55 -0
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +81 -0
- package/src/server/index.ts +154 -16
- package/src/server/run-repository.ts +153 -136
- 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/example-review.md +22 -14
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +55 -32
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +7 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
- package/src/tests/baseline.test.ts +79 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +56 -5
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +149 -0
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +81 -5
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +86 -23
- package/src/tests/server.test.ts +184 -96
- package/src/tests/storage.test.ts +449 -0
- package/src/server/client/constants.ts +0 -91
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
package/src/core/run-status.ts
CHANGED
|
@@ -1,23 +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
|
|
8
|
-
*
|
|
9
|
-
* não
|
|
7
|
+
* Toda ação que muda o estado pinga o `updatedAt`. Execuções ativas têm um
|
|
8
|
+
* `heartbeatAt` próprio (renovado por `status ping` ou pela correção); a UI usa
|
|
9
|
+
* esse campo — não o `updatedAt` — para detectar timeout (display-only).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { join } from "node:path";
|
|
13
12
|
import type { FindingsStateFile } from "./findings-state";
|
|
14
|
-
import { RUN_STATUS_FILE } from "./paths";
|
|
15
|
-
import { sanitizeBranch } from "./report-builder";
|
|
16
13
|
import { systemClock } from "./services/clock";
|
|
17
|
-
import {
|
|
14
|
+
import type { ReviewRecord, ReviewStatus, StorageProvider } from "./storage/types";
|
|
18
15
|
|
|
19
16
|
/** Estados de execução de uma run (persistidos). */
|
|
20
|
-
export type RunStatus =
|
|
17
|
+
export type RunStatus = ReviewStatus;
|
|
21
18
|
|
|
22
19
|
/** Valores canônicos de status (ordem de exibição/lifecycle). */
|
|
23
20
|
export const RUN_STATUSES: readonly RunStatus[] = [
|
|
@@ -27,56 +24,64 @@ export const RUN_STATUSES: readonly RunStatus[] = [
|
|
|
27
24
|
"fix_done",
|
|
28
25
|
];
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export type RunStatusFile = {
|
|
34
|
-
version: 1;
|
|
35
|
-
slug: string;
|
|
36
|
-
branch: string;
|
|
37
|
-
status: RunStatus;
|
|
38
|
-
/** Início do ciclo de revisão atual (reiniciado a cada `prepare`). */
|
|
39
|
-
startedAt: string;
|
|
40
|
-
/** Última ação importante (ping). */
|
|
41
|
-
updatedAt: string;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** Caminho do `run.json` dentro da pasta da run. */
|
|
45
|
-
export function runStatusPath(runDir: string): string {
|
|
46
|
-
return join(runDir, RUN_STATUS_FILE);
|
|
27
|
+
/** `true` para status de execução (recebem heartbeat e podem dar timeout). */
|
|
28
|
+
export function isActiveRunStatus(status: RunStatus | null): boolean {
|
|
29
|
+
return status === "reviewing" || status === "fixing";
|
|
47
30
|
}
|
|
48
31
|
|
|
49
|
-
/** Lê o
|
|
50
|
-
export function readRunStatus(
|
|
51
|
-
|
|
32
|
+
/** Lê o status atual (null se a review não existir). */
|
|
33
|
+
export function readRunStatus(
|
|
34
|
+
storage: StorageProvider,
|
|
35
|
+
reviewId: string,
|
|
36
|
+
): ReviewRecord | null {
|
|
37
|
+
return storage.getReview(reviewId);
|
|
52
38
|
}
|
|
53
39
|
|
|
54
40
|
export type WriteRunStatusOptions = {
|
|
55
|
-
branch: string;
|
|
56
41
|
status: RunStatus;
|
|
57
42
|
/** Reinicia o `startedAt` — usado no início de uma nova revisão. */
|
|
58
43
|
resetStartedAt?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Marca a execução como viva agora. Implícito no início de um ciclo
|
|
46
|
+
* (`resetStartedAt`); pings explícitos usam `touchRunStatus`.
|
|
47
|
+
*/
|
|
48
|
+
touch?: boolean;
|
|
59
49
|
now?: string;
|
|
60
50
|
};
|
|
61
51
|
|
|
62
|
-
/** Grava/atualiza o
|
|
52
|
+
/** Grava/atualiza o status da review (ping no `updatedAt`). */
|
|
63
53
|
export function writeRunStatus(
|
|
64
|
-
|
|
54
|
+
storage: StorageProvider,
|
|
55
|
+
reviewId: string,
|
|
65
56
|
options: WriteRunStatusOptions,
|
|
66
|
-
):
|
|
67
|
-
const files = new ProjectFiles();
|
|
57
|
+
): ReviewRecord {
|
|
68
58
|
const now = options.now ?? systemClock();
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
slug: sanitizeBranch(options.branch),
|
|
73
|
-
branch: options.branch,
|
|
59
|
+
const active = isActiveRunStatus(options.status);
|
|
60
|
+
const touch = options.touch ?? options.resetStartedAt ?? false;
|
|
61
|
+
const updated = storage.updateReview(reviewId, {
|
|
74
62
|
status: options.status,
|
|
75
|
-
|
|
63
|
+
resetStartedAt: options.resetStartedAt,
|
|
64
|
+
heartbeatAt: active && touch ? now : undefined,
|
|
65
|
+
resetHeartbeat: !active,
|
|
76
66
|
updatedAt: now,
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
return
|
|
67
|
+
});
|
|
68
|
+
if (!updated) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
69
|
+
return updated;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Heartbeat explícito de uma execução em andamento (skill viva).
|
|
74
|
+
* Só age em `reviewing`/`fixing`; status terminal é devolvido sem alteração.
|
|
75
|
+
*/
|
|
76
|
+
export function touchRunStatus(
|
|
77
|
+
storage: StorageProvider,
|
|
78
|
+
reviewId: string,
|
|
79
|
+
now: string = systemClock(),
|
|
80
|
+
): ReviewRecord {
|
|
81
|
+
const review = storage.getReview(reviewId);
|
|
82
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
83
|
+
if (!isActiveRunStatus(review.status)) return review;
|
|
84
|
+
return storage.updateReview(reviewId, { heartbeatAt: now, updatedAt: now }) ?? review;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
/**
|
|
@@ -105,14 +110,18 @@ export function statusAfterFindingsChange(
|
|
|
105
110
|
return previous ?? "review_done";
|
|
106
111
|
}
|
|
107
112
|
|
|
108
|
-
/** Ping: deriva o status da mudança nos findings e grava
|
|
113
|
+
/** Ping: deriva o status da mudança nos findings e grava na review. */
|
|
109
114
|
export function pingFindingsStatus(
|
|
110
|
-
|
|
115
|
+
storage: StorageProvider,
|
|
116
|
+
reviewId: string,
|
|
111
117
|
state: FindingsStateFile,
|
|
112
|
-
):
|
|
113
|
-
const previous =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
status
|
|
118
|
+
): ReviewRecord {
|
|
119
|
+
const previous = storage.getReview(reviewId)?.status ?? null;
|
|
120
|
+
const status = statusAfterFindingsChange(previous, state);
|
|
121
|
+
return writeRunStatus(storage, reviewId, {
|
|
122
|
+
status,
|
|
123
|
+
// Só a correção em andamento é uma execução: triagem/reviewing não
|
|
124
|
+
// devem renovar o heartbeat (era a origem do "timeout falso").
|
|
125
|
+
touch: status === "fixing",
|
|
117
126
|
});
|
|
118
127
|
}
|
|
@@ -52,6 +52,22 @@ export class GitRepository {
|
|
|
52
52
|
return branch.length > 0 ? branch : null;
|
|
53
53
|
}
|
|
54
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
|
+
|
|
55
71
|
/**
|
|
56
72
|
* `true` se `ancestor` é ancestral de `descendant`. Qualquer outro desfecho
|
|
57
73
|
* (ref desconhecida, exit ≠ 0) devolve `false` — cai no review completo.
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
* manter o comportamento fail-open do motor.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
10
17
|
import { dirname, isAbsolute, join } from "node:path";
|
|
11
18
|
|
|
12
19
|
/** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
|
|
@@ -67,6 +74,11 @@ export class ProjectFiles {
|
|
|
67
74
|
mkdirSync(this.resolve(path), { recursive: true });
|
|
68
75
|
}
|
|
69
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
|
+
|
|
70
82
|
/** `true` se o caminho existe. */
|
|
71
83
|
exists(path: string): boolean {
|
|
72
84
|
return existsSync(this.resolve(path));
|
|
@@ -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,271 @@
|
|
|
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
|
+
if (patch.resetHeartbeat) updated.heartbeatAt = undefined;
|
|
106
|
+
else if (patch.heartbeatAt !== undefined) updated.heartbeatAt = patch.heartbeatAt;
|
|
107
|
+
updated.updatedAt = patch.updatedAt ?? now;
|
|
108
|
+
return this.writeReview(updated);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
deleteReview(id: string): void {
|
|
112
|
+
const review = this.getReview(id);
|
|
113
|
+
if (!review) return;
|
|
114
|
+
this.files.removeDir(this.reviewDir(review));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
// Findings
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
readFindings(reviewId: string): FindingsStateFile | null {
|
|
122
|
+
const review = this.getReview(reviewId);
|
|
123
|
+
if (!review) return null;
|
|
124
|
+
const raw = this.files.readJson<FindingsStateFile>(
|
|
125
|
+
join(this.reviewDir(review), FINDINGS_FILE),
|
|
126
|
+
);
|
|
127
|
+
if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
128
|
+
return raw;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
writeFindings(reviewId: string, state: FindingsStateFile): void {
|
|
132
|
+
const review = this.getReview(reviewId);
|
|
133
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
134
|
+
this.writeJson(join(this.reviewDir(review), FINDINGS_FILE), state);
|
|
135
|
+
this.writeReview({
|
|
136
|
+
...review,
|
|
137
|
+
reviewedSha: state.reviewed?.headSha,
|
|
138
|
+
reviewedAt: state.reviewed?.at,
|
|
139
|
+
updatedAt: state.updatedAt || systemClock(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
// Artefatos
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
readArtifact(reviewId: string, name: string): ArtifactRecord | null {
|
|
148
|
+
const review = this.getReview(reviewId);
|
|
149
|
+
if (!review) return null;
|
|
150
|
+
return this.readArtifacts(review)[name] ?? null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
writeArtifact(reviewId: string, input: WriteArtifactInput): ArtifactRecord {
|
|
154
|
+
const review = this.getReview(reviewId);
|
|
155
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
156
|
+
const index = this.readArtifacts(review);
|
|
157
|
+
const now = systemClock();
|
|
158
|
+
const existing = index[input.name];
|
|
159
|
+
const contentType =
|
|
160
|
+
input.contentType ??
|
|
161
|
+
(input.name.toLowerCase().endsWith(".json")
|
|
162
|
+
? "application/json; charset=utf-8"
|
|
163
|
+
: "text/markdown; charset=utf-8");
|
|
164
|
+
const record: ArtifactRecord = {
|
|
165
|
+
id: existing?.id ?? randomUUID(),
|
|
166
|
+
reviewId,
|
|
167
|
+
name: input.name,
|
|
168
|
+
kind: input.kind,
|
|
169
|
+
content: input.content,
|
|
170
|
+
contentType,
|
|
171
|
+
createdAt: existing?.createdAt ?? now,
|
|
172
|
+
updatedAt: now,
|
|
173
|
+
};
|
|
174
|
+
index[input.name] = record;
|
|
175
|
+
this.writeJson(join(this.reviewDir(review), ARTIFACTS_FILE), index);
|
|
176
|
+
return record;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
listArtifacts(reviewId: string): ArtifactRecord[] {
|
|
180
|
+
const review = this.getReview(reviewId);
|
|
181
|
+
if (!review) return [];
|
|
182
|
+
return Object.values(this.readArtifacts(review)).sort((a, b) =>
|
|
183
|
+
a.name.localeCompare(b.name),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
deleteArtifact(reviewId: string, name: string): void {
|
|
188
|
+
const review = this.getReview(reviewId);
|
|
189
|
+
if (!review) return;
|
|
190
|
+
const index = this.readArtifacts(review);
|
|
191
|
+
if (!index[name]) return;
|
|
192
|
+
delete index[name];
|
|
193
|
+
this.writeJson(join(this.reviewDir(review), ARTIFACTS_FILE), index);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
// Manutenção / ciclo de vida
|
|
198
|
+
// -----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
maintain(): void {
|
|
201
|
+
// Nada a compactar: arquivos JSON são reescritos por inteiro.
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
location(reviewId: string): string {
|
|
205
|
+
const review = this.getReview(reviewId);
|
|
206
|
+
return review
|
|
207
|
+
? `file:${join(this.reviewDir(review), FINDINGS_FILE)}#${reviewId}`
|
|
208
|
+
: `file:${this.root}#${reviewId}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
close(): void {
|
|
212
|
+
// Sem recurso aberto.
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -----------------------------------------------------------------------
|
|
216
|
+
// Internos
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
private repoDir(repoKey: string): string {
|
|
220
|
+
const hash = createHash("sha256").update(repoKey).digest("hex").slice(0, 16);
|
|
221
|
+
return join(this.root, hash);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private reviewDir(review: ReviewRecord): string {
|
|
225
|
+
return join(this.repoDir(review.repoKey), review.slug);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private writeReview(record: ReviewRecord): ReviewRecord {
|
|
229
|
+
this.writeJson(join(this.reviewDir(record), REVIEW_FILE), record);
|
|
230
|
+
return record;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private readReviews(repoKey?: string): ReviewRecord[] {
|
|
234
|
+
const roots = repoKey ? [this.repoDir(repoKey)] : this.subdirectories(this.root);
|
|
235
|
+
const reviews: ReviewRecord[] = [];
|
|
236
|
+
for (const repoDir of roots) {
|
|
237
|
+
for (const slugDir of this.subdirectories(repoDir)) {
|
|
238
|
+
const review = this.files.readJson<ReviewRecord>(
|
|
239
|
+
join(slugDir, REVIEW_FILE),
|
|
240
|
+
);
|
|
241
|
+
if (review && (!repoKey || review.repoKey === repoKey)) {
|
|
242
|
+
reviews.push(review);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return reviews;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private readArtifacts(review: ReviewRecord): Record<string, ArtifactRecord> {
|
|
250
|
+
return (
|
|
251
|
+
this.files.readJson<Record<string, ArtifactRecord>>(
|
|
252
|
+
join(this.reviewDir(review), ARTIFACTS_FILE),
|
|
253
|
+
) ?? {}
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private writeJson(path: string, value: unknown): void {
|
|
258
|
+
this.files.writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private subdirectories(dir: string): string[] {
|
|
262
|
+
if (!existsSync(dir)) return [];
|
|
263
|
+
try {
|
|
264
|
+
return readdirSync(dir)
|
|
265
|
+
.map((name) => join(dir, name))
|
|
266
|
+
.filter((path) => statSync(path).isDirectory());
|
|
267
|
+
} catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -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
|
+
}
|