@mainahq/core 0.2.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 +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { HookContext, HookEvent } from "../runner";
|
|
6
|
+
import { executeHook, runHooks, scanHooks } from "../runner";
|
|
7
|
+
|
|
8
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function makeTmpDir(): string {
|
|
11
|
+
const dir = join(
|
|
12
|
+
tmpdir(),
|
|
13
|
+
`maina-hooks-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
14
|
+
);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeHookScript(dir: string, event: HookEvent, body: string): string {
|
|
20
|
+
const hooksDir = join(dir, "hooks");
|
|
21
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
22
|
+
const scriptPath = join(hooksDir, `${event}.sh`);
|
|
23
|
+
writeFileSync(scriptPath, `#!/bin/sh\n${body}\n`);
|
|
24
|
+
chmodSync(scriptPath, 0o755);
|
|
25
|
+
return scriptPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeContext(event: HookEvent, mainaDir: string): HookContext {
|
|
29
|
+
return {
|
|
30
|
+
event,
|
|
31
|
+
repoRoot: join(mainaDir, ".."),
|
|
32
|
+
mainaDir,
|
|
33
|
+
stagedFiles: ["src/index.ts"],
|
|
34
|
+
branch: "main",
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── scanHooks ───────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("scanHooks", () => {
|
|
42
|
+
let tmpDir: string;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
tmpDir = makeTmpDir();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns empty array when hooks directory does not exist", async () => {
|
|
53
|
+
const result = await scanHooks(tmpDir, "pre-commit");
|
|
54
|
+
expect(result).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("returns empty array when no hook for the event exists", async () => {
|
|
58
|
+
mkdirSync(join(tmpDir, "hooks"), { recursive: true });
|
|
59
|
+
const result = await scanHooks(tmpDir, "pre-commit");
|
|
60
|
+
expect(result).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("finds a pre-commit hook script", async () => {
|
|
64
|
+
const scriptPath = makeHookScript(tmpDir, "pre-commit", "exit 0");
|
|
65
|
+
const result = await scanHooks(tmpDir, "pre-commit");
|
|
66
|
+
expect(result).toEqual([scriptPath]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("does not return hooks for different events", async () => {
|
|
70
|
+
makeHookScript(tmpDir, "post-commit", "exit 0");
|
|
71
|
+
const result = await scanHooks(tmpDir, "pre-commit");
|
|
72
|
+
expect(result).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("finds hooks for all supported events", async () => {
|
|
76
|
+
const events: HookEvent[] = [
|
|
77
|
+
"pre-commit",
|
|
78
|
+
"post-commit",
|
|
79
|
+
"pre-verify",
|
|
80
|
+
"post-verify",
|
|
81
|
+
"pre-review",
|
|
82
|
+
"post-learn",
|
|
83
|
+
];
|
|
84
|
+
for (const event of events) {
|
|
85
|
+
makeHookScript(tmpDir, event, "exit 0");
|
|
86
|
+
}
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
const result = await scanHooks(tmpDir, event);
|
|
89
|
+
expect(result.length).toBe(1);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ─── executeHook ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe("executeHook", () => {
|
|
97
|
+
let tmpDir: string;
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
tmpDir = makeTmpDir();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("exit code 0 returns continue", async () => {
|
|
108
|
+
const scriptPath = makeHookScript(tmpDir, "pre-commit", "exit 0");
|
|
109
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
110
|
+
const result = await executeHook(scriptPath, ctx);
|
|
111
|
+
expect(result.status).toBe("continue");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("exit code 2 returns block with message", async () => {
|
|
115
|
+
const scriptPath = makeHookScript(
|
|
116
|
+
tmpDir,
|
|
117
|
+
"pre-commit",
|
|
118
|
+
'echo "blocked by policy" >&2\nexit 2',
|
|
119
|
+
);
|
|
120
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
121
|
+
const result = await executeHook(scriptPath, ctx);
|
|
122
|
+
expect(result.status).toBe("block");
|
|
123
|
+
if (result.status === "block") {
|
|
124
|
+
expect(result.message).toContain("blocked by policy");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("other exit codes return warn with message", async () => {
|
|
129
|
+
const scriptPath = makeHookScript(
|
|
130
|
+
tmpDir,
|
|
131
|
+
"pre-commit",
|
|
132
|
+
'echo "something wrong" >&2\nexit 1',
|
|
133
|
+
);
|
|
134
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
135
|
+
const result = await executeHook(scriptPath, ctx);
|
|
136
|
+
expect(result.status).toBe("warn");
|
|
137
|
+
if (result.status === "warn") {
|
|
138
|
+
expect(result.message).toContain("something wrong");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("hook receives JSON context on stdin", async () => {
|
|
143
|
+
// Script reads stdin and writes it to a file so we can verify
|
|
144
|
+
const outputFile = join(tmpDir, "stdin-capture.json");
|
|
145
|
+
const scriptPath = makeHookScript(
|
|
146
|
+
tmpDir,
|
|
147
|
+
"pre-commit",
|
|
148
|
+
`cat > "${outputFile}"\nexit 0`,
|
|
149
|
+
);
|
|
150
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
151
|
+
await executeHook(scriptPath, ctx);
|
|
152
|
+
|
|
153
|
+
const captured = await Bun.file(outputFile).text();
|
|
154
|
+
const parsed = JSON.parse(captured);
|
|
155
|
+
expect(parsed.event).toBe("pre-commit");
|
|
156
|
+
expect(parsed.mainaDir).toBe(tmpDir);
|
|
157
|
+
expect(parsed.stagedFiles).toEqual(["src/index.ts"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("non-existent hook file returns warn", async () => {
|
|
161
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
162
|
+
const result = await executeHook(
|
|
163
|
+
join(tmpDir, "hooks", "nonexistent.sh"),
|
|
164
|
+
ctx,
|
|
165
|
+
);
|
|
166
|
+
expect(result.status).toBe("warn");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("hook stderr is captured in block message", async () => {
|
|
170
|
+
const scriptPath = makeHookScript(
|
|
171
|
+
tmpDir,
|
|
172
|
+
"pre-commit",
|
|
173
|
+
'echo "line 1" >&2\necho "line 2" >&2\nexit 2',
|
|
174
|
+
);
|
|
175
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
176
|
+
const result = await executeHook(scriptPath, ctx);
|
|
177
|
+
expect(result.status).toBe("block");
|
|
178
|
+
if (result.status === "block") {
|
|
179
|
+
expect(result.message).toContain("line 1");
|
|
180
|
+
expect(result.message).toContain("line 2");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── runHooks ────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe("runHooks", () => {
|
|
188
|
+
let tmpDir: string;
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
tmpDir = makeTmpDir();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("returns continue when no hooks exist", async () => {
|
|
199
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
200
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
201
|
+
expect(result.status).toBe("continue");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("returns continue when hook exits 0", async () => {
|
|
205
|
+
makeHookScript(tmpDir, "pre-commit", "exit 0");
|
|
206
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
207
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
208
|
+
expect(result.status).toBe("continue");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("returns block when hook exits 2", async () => {
|
|
212
|
+
makeHookScript(tmpDir, "pre-commit", 'echo "nope" >&2\nexit 2');
|
|
213
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
214
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
215
|
+
expect(result.status).toBe("block");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("returns warn when hook exits with non-zero non-2 code", async () => {
|
|
219
|
+
makeHookScript(tmpDir, "pre-commit", 'echo "warning" >&2\nexit 1');
|
|
220
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
221
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
222
|
+
expect(result.status).toBe("warn");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ─── CommitGate integration ──────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe("CommitGate", () => {
|
|
229
|
+
let tmpDir: string;
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
tmpDir = makeTmpDir();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
afterEach(() => {
|
|
236
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should execute .maina/hooks/pre-commit.sh if present", async () => {
|
|
240
|
+
makeHookScript(tmpDir, "pre-commit", "exit 0");
|
|
241
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
242
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
243
|
+
expect(result.status).toBe("continue");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("pre-commit hook with exit 0 passes", async () => {
|
|
247
|
+
makeHookScript(tmpDir, "pre-commit", "exit 0");
|
|
248
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
249
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
250
|
+
expect(result.status).toBe("continue");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("pre-commit hook with exit 2 blocks", async () => {
|
|
254
|
+
makeHookScript(
|
|
255
|
+
tmpDir,
|
|
256
|
+
"pre-commit",
|
|
257
|
+
'echo "commit blocked by hook" >&2\nexit 2',
|
|
258
|
+
);
|
|
259
|
+
const ctx = makeContext("pre-commit", tmpDir);
|
|
260
|
+
const result = await runHooks(tmpDir, "pre-commit", ctx);
|
|
261
|
+
expect(result.status).toBe("block");
|
|
262
|
+
if (result.status === "block") {
|
|
263
|
+
expect(result.message).toContain("commit blocked by hook");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type HookEvent =
|
|
5
|
+
| "pre-commit"
|
|
6
|
+
| "post-commit"
|
|
7
|
+
| "pre-verify"
|
|
8
|
+
| "post-verify"
|
|
9
|
+
| "pre-review"
|
|
10
|
+
| "post-learn";
|
|
11
|
+
|
|
12
|
+
export interface HookContext {
|
|
13
|
+
event: HookEvent;
|
|
14
|
+
repoRoot: string;
|
|
15
|
+
mainaDir: string;
|
|
16
|
+
stagedFiles?: string[];
|
|
17
|
+
branch?: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type HookResult =
|
|
22
|
+
| { status: "continue" }
|
|
23
|
+
| { status: "block"; message: string }
|
|
24
|
+
| { status: "warn"; message: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scan the hooks directory for scripts matching the given event.
|
|
28
|
+
* Looks for `.maina/hooks/<event>.sh`.
|
|
29
|
+
*/
|
|
30
|
+
export async function scanHooks(
|
|
31
|
+
mainaDir: string,
|
|
32
|
+
event: HookEvent,
|
|
33
|
+
): Promise<string[]> {
|
|
34
|
+
const hooksDir = join(mainaDir, "hooks");
|
|
35
|
+
const scriptPath = join(hooksDir, `${event}.sh`);
|
|
36
|
+
if (existsSync(scriptPath)) {
|
|
37
|
+
return [scriptPath];
|
|
38
|
+
}
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute a single hook script, piping the JSON context on stdin.
|
|
44
|
+
*
|
|
45
|
+
* Exit code semantics:
|
|
46
|
+
* 0 = continue
|
|
47
|
+
* 2 = block (stderr captured as message)
|
|
48
|
+
* other = warn and continue (stderr captured as message)
|
|
49
|
+
*/
|
|
50
|
+
export async function executeHook(
|
|
51
|
+
hookPath: string,
|
|
52
|
+
context: HookContext,
|
|
53
|
+
): Promise<HookResult> {
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(hookPath)) {
|
|
56
|
+
return {
|
|
57
|
+
status: "warn",
|
|
58
|
+
message: `Hook not found: ${hookPath}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const jsonInput = JSON.stringify(context);
|
|
63
|
+
|
|
64
|
+
const proc = Bun.spawn(["sh", hookPath], {
|
|
65
|
+
stdin: new Blob([jsonInput]),
|
|
66
|
+
stdout: "pipe",
|
|
67
|
+
stderr: "pipe",
|
|
68
|
+
cwd: context.repoRoot,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const stderr = await new Response(proc.stderr).text();
|
|
72
|
+
const exitCode = await proc.exited;
|
|
73
|
+
|
|
74
|
+
if (exitCode === 0) {
|
|
75
|
+
return { status: "continue" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const message = stderr.trim() || `Hook exited with code ${exitCode}`;
|
|
79
|
+
|
|
80
|
+
if (exitCode === 2) {
|
|
81
|
+
return { status: "block", message };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { status: "warn", message };
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return {
|
|
87
|
+
status: "warn",
|
|
88
|
+
message: e instanceof Error ? e.message : String(e),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Scan for all hooks matching the event and execute them in sequence.
|
|
95
|
+
*
|
|
96
|
+
* - If any hook returns "block", stop immediately and return block.
|
|
97
|
+
* - If any hook returns "warn", continue but collect warnings.
|
|
98
|
+
* - If all return "continue", return continue.
|
|
99
|
+
*/
|
|
100
|
+
export async function runHooks(
|
|
101
|
+
mainaDir: string,
|
|
102
|
+
event: HookEvent,
|
|
103
|
+
context: HookContext,
|
|
104
|
+
): Promise<HookResult> {
|
|
105
|
+
const hooks = await scanHooks(mainaDir, event);
|
|
106
|
+
|
|
107
|
+
if (hooks.length === 0) {
|
|
108
|
+
return { status: "continue" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const warnings: string[] = [];
|
|
112
|
+
|
|
113
|
+
for (const hookPath of hooks) {
|
|
114
|
+
const result = await executeHook(hookPath, context);
|
|
115
|
+
|
|
116
|
+
if (result.status === "block") {
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.status === "warn") {
|
|
121
|
+
warnings.push(result.message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (warnings.length > 0) {
|
|
126
|
+
return { status: "warn", message: warnings.join("\n") };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { status: "continue" };
|
|
130
|
+
}
|