oh-my-harness 0.4.2 → 0.6.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 -0
- package/dist/cli/command-checker.d.ts +15 -0
- package/dist/cli/command-checker.js +104 -0
- package/dist/cli/commands/init.js +4 -1
- package/dist/cli/commands/test.d.ts +12 -0
- package/dist/cli/commands/test.js +139 -0
- package/dist/cli/event-verifier.d.ts +8 -0
- package/dist/cli/event-verifier.js +29 -0
- package/dist/cli/harness-tester.d.ts +37 -0
- package/dist/cli/harness-tester.js +289 -0
- package/dist/cli/index.js +15 -0
- package/dist/cli/stats/App.d.ts +8 -0
- package/dist/cli/stats/App.js +67 -0
- package/dist/cli/stats/components/Blocks.d.ts +8 -0
- package/dist/cli/stats/components/Blocks.js +14 -0
- package/dist/cli/stats/components/HitBar.d.ts +8 -0
- package/dist/cli/stats/components/HitBar.js +13 -0
- package/dist/cli/stats/components/Overview.d.ts +7 -0
- package/dist/cli/stats/components/Overview.js +6 -0
- package/dist/cli/stats/components/Timeline.d.ts +7 -0
- package/dist/cli/stats/components/Timeline.js +21 -0
- package/dist/cli/stats/data.d.ts +47 -0
- package/dist/cli/stats/data.js +118 -0
- package/dist/cli/stats/index.d.ts +4 -0
- package/dist/cli/stats/index.js +10 -0
- package/dist/cli/tool-checker.d.ts +6 -0
- package/dist/cli/tool-checker.js +30 -9
- package/dist/cli/tui/init-flow.js +20 -33
- package/dist/core/harness-converter-v2.js +7 -7
- package/dist/core/harness-converter.d.ts +17 -0
- package/dist/core/harness-converter.js +50 -103
- package/dist/nl/prompt-templates.js +3 -5
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -368,6 +368,24 @@ No code changes required. The registry auto-discovers it.
|
|
|
368
368
|
|
|
369
369
|
---
|
|
370
370
|
|
|
371
|
+
## Contributing
|
|
372
|
+
|
|
373
|
+
Contributions are welcome! Please read the [Contributing Guide](CONTRIBUTING.md) before submitting a PR.
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Support This Project
|
|
378
|
+
|
|
379
|
+
oh-my-harness is free and open source. Here's how you can help:
|
|
380
|
+
|
|
381
|
+
- **Star** — [Give a star](https://github.com/kyu1204/oh-my-harness) to help others discover the project
|
|
382
|
+
- **Report Bugs** — [Open an issue](https://github.com/kyu1204/oh-my-harness/issues/new) when something doesn't work
|
|
383
|
+
- **Request Features** — [Suggest ideas](https://github.com/kyu1204/oh-my-harness/issues/new) for new presets, hooks, or emitters
|
|
384
|
+
- **Contribute** — Fix a bug, add a preset, or improve docs — PRs are always welcome
|
|
385
|
+
- **Spread the Word** — Share oh-my-harness with your team or community
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
371
389
|
## License
|
|
372
390
|
|
|
373
391
|
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface CommandCheckResult {
|
|
2
|
+
command: string;
|
|
3
|
+
category: string;
|
|
4
|
+
executable: boolean;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function extractExecutable(command: string): string;
|
|
8
|
+
export declare function checkCommandExecutable(binary: string): Promise<boolean>;
|
|
9
|
+
export declare function extractPreCommitCommands(hookContent: string): string[];
|
|
10
|
+
export declare function extractPostSaveCommands(hookContent: string): string[];
|
|
11
|
+
export declare function checkHarnessCommands(hooks: {
|
|
12
|
+
event: string;
|
|
13
|
+
matcher: string;
|
|
14
|
+
command: string;
|
|
15
|
+
}[], projectDir: string): Promise<CommandCheckResult[]>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
// 명령어에서 바이너리 추출 (첫 토큰, 래퍼 처리)
|
|
5
|
+
export function extractExecutable(command) {
|
|
6
|
+
const parts = command.trim().split(/\s+/);
|
|
7
|
+
// npm run X → npm
|
|
8
|
+
// npx X → npx
|
|
9
|
+
// bash script.sh → bash
|
|
10
|
+
return parts[0];
|
|
11
|
+
}
|
|
12
|
+
// 명령어 실행 가능 여부 확인
|
|
13
|
+
export async function checkCommandExecutable(binary) {
|
|
14
|
+
try {
|
|
15
|
+
await execFileAsync("which", [binary]);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// pre-commit hook 스크립트에서 실행되는 명령어 추출
|
|
23
|
+
export function extractPreCommitCommands(hookContent) {
|
|
24
|
+
const commands = [];
|
|
25
|
+
// 패턴: if ! <command> 또는 직접 명령 실행
|
|
26
|
+
const matches = hookContent.matchAll(/if\s+!\s+(.+?)\s+>&?2/g);
|
|
27
|
+
for (const match of matches) {
|
|
28
|
+
commands.push(match[1].trim());
|
|
29
|
+
}
|
|
30
|
+
return commands;
|
|
31
|
+
}
|
|
32
|
+
// post-save hook 스크립트에서 실행되는 명령어 추출
|
|
33
|
+
export function extractPostSaveCommands(hookContent) {
|
|
34
|
+
const commands = [];
|
|
35
|
+
// 패턴: <command> "$FILE_PATH" 또는 <command> --fix "$FILE_PATH"
|
|
36
|
+
const lines = hookContent.split("\n");
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
// eslint --fix "$FILE_PATH" 패턴
|
|
40
|
+
if (trimmed.includes("$FILE_PATH") &&
|
|
41
|
+
!trimmed.startsWith("#") &&
|
|
42
|
+
!trimmed.startsWith("if") &&
|
|
43
|
+
!trimmed.startsWith("FILE_PATH") &&
|
|
44
|
+
!trimmed.startsWith("BASENAME") &&
|
|
45
|
+
!trimmed.startsWith("echo ")) {
|
|
46
|
+
const cmd = trimmed.split(/\s+"\$FILE_PATH"/)[0].trim();
|
|
47
|
+
if (cmd && !cmd.startsWith("[")) {
|
|
48
|
+
commands.push(cmd);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return commands;
|
|
53
|
+
}
|
|
54
|
+
// 전체 명령어 체크
|
|
55
|
+
export async function checkHarnessCommands(hooks, projectDir) {
|
|
56
|
+
const results = [];
|
|
57
|
+
const fs = await import("node:fs/promises");
|
|
58
|
+
const path = await import("node:path");
|
|
59
|
+
for (const hook of hooks) {
|
|
60
|
+
const scriptPath = hook.command.replace(/^bash\s+/, "");
|
|
61
|
+
const fullPath = path.join(projectDir, scriptPath);
|
|
62
|
+
let content;
|
|
63
|
+
try {
|
|
64
|
+
content = await fs.readFile(fullPath, "utf-8");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue; // 스크립트 파일 없으면 스킵
|
|
68
|
+
}
|
|
69
|
+
const scriptName = path.basename(scriptPath);
|
|
70
|
+
// pre-commit gate류 — 파일명 매칭 확장 + 내용 기반 감지
|
|
71
|
+
if (scriptName.includes("pre-commit") ||
|
|
72
|
+
scriptName.includes("test-gate") ||
|
|
73
|
+
scriptName.includes("typecheck-gate") ||
|
|
74
|
+
scriptName.includes("before-commit") ||
|
|
75
|
+
/if\s+!\s+.+\s+>&?2/.test(content)) {
|
|
76
|
+
const commands = extractPreCommitCommands(content);
|
|
77
|
+
const isTypecheck = scriptName.includes("typecheck") || commands.some((c) => /\btsc\b/.test(c));
|
|
78
|
+
const category = isTypecheck ? "commit-typecheck-gate" : "commit-test-gate";
|
|
79
|
+
for (const cmd of commands) {
|
|
80
|
+
const binary = extractExecutable(cmd);
|
|
81
|
+
const executable = await checkCommandExecutable(binary);
|
|
82
|
+
results.push({ command: cmd, category, executable });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// post-save류 (lint-on-save, format-on-save)
|
|
86
|
+
if (scriptName.includes("post-save") ||
|
|
87
|
+
scriptName.includes("lint-on-save") ||
|
|
88
|
+
scriptName.includes("format-on-save")) {
|
|
89
|
+
const commands = extractPostSaveCommands(content);
|
|
90
|
+
const category = scriptName.includes("format") ? "format-on-save" : "lint-on-save";
|
|
91
|
+
for (const cmd of commands) {
|
|
92
|
+
const binary = extractExecutable(cmd);
|
|
93
|
+
const executable = await checkCommandExecutable(binary);
|
|
94
|
+
results.push({ command: cmd, category, executable });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// auto-pr (gh CLI 체크)
|
|
98
|
+
if (scriptName.includes("auto-pr")) {
|
|
99
|
+
const hasGh = await checkCommandExecutable("gh");
|
|
100
|
+
results.push({ command: "gh", category: "auto-pr", executable: hasGh });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
@@ -131,7 +131,10 @@ async function initWithNL(projectDir, presetsDir, options) {
|
|
|
131
131
|
const stackNames = harness.project.stacks.map((s) => `${s.name} (${s.framework})`).join(", ");
|
|
132
132
|
console.log(`\nStacks: ${stackNames}`);
|
|
133
133
|
console.log(`Rules: ${harness.rules.length}`);
|
|
134
|
-
|
|
134
|
+
const { mergeEnforcementAndHooks } = await import("../../core/harness-converter.js");
|
|
135
|
+
const allHooks = mergeEnforcementAndHooks(harness);
|
|
136
|
+
const hookSummary = allHooks.map((h) => h.block).join(", ") || "none";
|
|
137
|
+
console.log(`Hooks: ${hookSummary}`);
|
|
135
138
|
if (!options.yes) {
|
|
136
139
|
const { confirm } = await import("@inquirer/prompts");
|
|
137
140
|
const ok = await confirm({ message: "Proceed with this configuration?", default: true });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TestResult } from "../harness-tester.js";
|
|
2
|
+
import type { CommandCheckResult } from "../command-checker.js";
|
|
3
|
+
export interface TestCommandOptions {
|
|
4
|
+
projectDir?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function formatCategoryName(category: string): string;
|
|
7
|
+
export declare function testCommand(options?: TestCommandOptions): Promise<{
|
|
8
|
+
passed: number;
|
|
9
|
+
failed: number;
|
|
10
|
+
results: TestResult[];
|
|
11
|
+
commandResults: CommandCheckResult[];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import { getRegisteredHooks, generateBlockTestCases, runTestCase } from "../harness-tester.js";
|
|
7
|
+
import { checkHarnessCommands } from "../command-checker.js";
|
|
8
|
+
import { HarnessConfigSchema } from "../../core/harness-schema.js";
|
|
9
|
+
import { mergeEnforcementAndHooks } from "../../core/harness-converter.js";
|
|
10
|
+
import { builtinBlocks } from "../../catalog/blocks/index.js";
|
|
11
|
+
export function formatCategoryName(category) {
|
|
12
|
+
const names = {
|
|
13
|
+
"path-guard": "File guards",
|
|
14
|
+
"command-guard": "Command guards",
|
|
15
|
+
"branch-guard": "Branch guard",
|
|
16
|
+
"lockfile-guard": "Lockfile guard",
|
|
17
|
+
"secret-file-guard": "Secret file guard",
|
|
18
|
+
"commit-test-gate": "Pre-commit test gate",
|
|
19
|
+
"commit-typecheck-gate": "Pre-commit typecheck gate",
|
|
20
|
+
"lint-on-save": "Lint on save",
|
|
21
|
+
"format-on-save": "Format on save",
|
|
22
|
+
"auto-pr": "Auto PR",
|
|
23
|
+
"tdd-guard": "TDD Guard",
|
|
24
|
+
};
|
|
25
|
+
return names[category] ?? category;
|
|
26
|
+
}
|
|
27
|
+
export async function testCommand(options = {}) {
|
|
28
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
29
|
+
// 1. harness.yaml 읽기
|
|
30
|
+
const harnessPath = path.join(projectDir, "harness.yaml");
|
|
31
|
+
let hookEntries = [];
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fs.readFile(harnessPath, "utf-8");
|
|
34
|
+
const parsed = yaml.load(raw);
|
|
35
|
+
const result = HarnessConfigSchema.safeParse(parsed);
|
|
36
|
+
if (result.success) {
|
|
37
|
+
// Merge enforcement-derived hooks with explicit hooks
|
|
38
|
+
hookEntries = mergeEnforcementAndHooks(result.data);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.log(chalk.yellow(`Warning: harness.yaml schema validation failed: ${result.error.message}`));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
const error = err;
|
|
46
|
+
if (error.code !== "ENOENT") {
|
|
47
|
+
console.log(chalk.red(`Error reading harness.yaml: ${error.message}`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
// ENOENT: harness.yaml 없으면 settings.json만으로 진행
|
|
51
|
+
}
|
|
52
|
+
// 2. settings.json에서 등록된 hook 가져오기
|
|
53
|
+
let hooks;
|
|
54
|
+
try {
|
|
55
|
+
hooks = await getRegisteredHooks(projectDir);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const error = err;
|
|
59
|
+
if (error.code === "ENOENT") {
|
|
60
|
+
console.log(chalk.red("harness not initialized. Run `omh init` first."));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.log(chalk.red(`Failed to read settings.json: ${error.message}`));
|
|
64
|
+
}
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
if (hooks.length === 0) {
|
|
68
|
+
console.log(chalk.yellow("No hooks registered in settings.json."));
|
|
69
|
+
return { passed: 0, failed: 0, results: [], commandResults: [] };
|
|
70
|
+
}
|
|
71
|
+
// 3. 시뮬레이션 테스트 케이스 생성 + 실행
|
|
72
|
+
p.intro(`${chalk.bgCyan(chalk.black(" omh test "))} ${chalk.dim("Harness dry-run verification")}`);
|
|
73
|
+
// 현재 브랜치 가져오기
|
|
74
|
+
let currentBranch;
|
|
75
|
+
try {
|
|
76
|
+
const { execFile: execFileCb } = await import("node:child_process");
|
|
77
|
+
const { promisify: promisifyFn } = await import("node:util");
|
|
78
|
+
const execFileAsync = promisifyFn(execFileCb);
|
|
79
|
+
const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { cwd: projectDir });
|
|
80
|
+
currentBranch = stdout.trim() || undefined;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// git 없으면 undefined
|
|
84
|
+
}
|
|
85
|
+
// Generate test cases from block-based hooks only
|
|
86
|
+
const testCases = generateBlockTestCases(hookEntries, builtinBlocks, currentBranch);
|
|
87
|
+
const results = [];
|
|
88
|
+
// 카테고리별 그룹핑
|
|
89
|
+
const categories = [...new Set(testCases.map((tc) => tc.category))];
|
|
90
|
+
for (const category of categories) {
|
|
91
|
+
const casesInCategory = testCases.filter((tc) => tc.category === category);
|
|
92
|
+
const categoryResults = [];
|
|
93
|
+
for (const tc of casesInCategory) {
|
|
94
|
+
const result = await runTestCase(projectDir, tc);
|
|
95
|
+
categoryResults.push(result);
|
|
96
|
+
results.push(result);
|
|
97
|
+
}
|
|
98
|
+
// 카테고리 출력
|
|
99
|
+
const lines = categoryResults.map((r) => {
|
|
100
|
+
if (r.passed) {
|
|
101
|
+
return ` ${chalk.green("✓")} ${r.testCase.name}`;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const hint = r.error ?? `expected ${r.testCase.expectation} but got ${r.actual}`;
|
|
105
|
+
return ` ${chalk.red("✗")} ${r.testCase.name}\n ${chalk.dim(hint)}`;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
p.note(lines.join("\n"), formatCategoryName(category));
|
|
109
|
+
}
|
|
110
|
+
// 4. 명령어 실행 가능 체크
|
|
111
|
+
const commandResults = await checkHarnessCommands(hooks, projectDir);
|
|
112
|
+
if (commandResults.length > 0) {
|
|
113
|
+
const cmdLines = commandResults.map((r) => {
|
|
114
|
+
if (r.executable) {
|
|
115
|
+
return ` ${chalk.green("✓")} ${r.command} — executable`;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
return ` ${chalk.red("✗")} ${r.command} — ${chalk.red("not found")}`;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
p.note(cmdLines.join("\n"), "Command checks");
|
|
122
|
+
}
|
|
123
|
+
// 5. 결과 요약
|
|
124
|
+
const hookPassed = results.filter((r) => r.passed).length;
|
|
125
|
+
const hookFailed = results.filter((r) => !r.passed).length;
|
|
126
|
+
const cmdPassed = commandResults.filter((r) => r.executable).length;
|
|
127
|
+
const cmdFailed = commandResults.filter((r) => !r.executable).length;
|
|
128
|
+
const totalPassed = hookPassed + cmdPassed;
|
|
129
|
+
const totalFailed = hookFailed + cmdFailed;
|
|
130
|
+
const total = totalPassed + totalFailed;
|
|
131
|
+
if (totalFailed === 0) {
|
|
132
|
+
p.outro(chalk.green(`${totalPassed}/${total} checks passed ✓`));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
p.outro(chalk.red(`${totalFailed}/${total} checks failed`));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
return { passed: totalPassed, failed: totalFailed, results, commandResults };
|
|
139
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type HookEvent } from "./event-logger.js";
|
|
2
|
+
export interface EventVerification {
|
|
3
|
+
verified: boolean;
|
|
4
|
+
matchedEvent?: HookEvent;
|
|
5
|
+
warning?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function getEventSnapshot(projectDir: string): Promise<number>;
|
|
8
|
+
export declare function verifyEventLogged(projectDir: string, snapshotCount: number, expectedHook: string, expectedDecision: "block" | "allow"): Promise<EventVerification>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readEvents } from "./event-logger.js";
|
|
2
|
+
// 테스트 실행 전 스냅샷 카운트 가져오기
|
|
3
|
+
export async function getEventSnapshot(projectDir) {
|
|
4
|
+
const events = await readEvents(projectDir);
|
|
5
|
+
return events.length;
|
|
6
|
+
}
|
|
7
|
+
// 테스트 실행 후 새 이벤트에서 hook 매칭 확인
|
|
8
|
+
export async function verifyEventLogged(projectDir, snapshotCount, expectedHook, expectedDecision) {
|
|
9
|
+
const events = await readEvents(projectDir);
|
|
10
|
+
const newEvents = events.slice(snapshotCount);
|
|
11
|
+
if (newEvents.length === 0) {
|
|
12
|
+
return { verified: false, warning: "No new events recorded in events.jsonl" };
|
|
13
|
+
}
|
|
14
|
+
// hook 이름 매칭 (정규화 후 동등 비교: catalog-command-guard.sh → command-guard)
|
|
15
|
+
const normalize = (name) => name.replace(/\.sh$/, "").replace(/^catalog-/, "").replace(/^harness-/, "");
|
|
16
|
+
const normalizedExpected = normalize(expectedHook);
|
|
17
|
+
const matched = newEvents.find((e) => normalize(e.hook) === normalizedExpected);
|
|
18
|
+
if (!matched) {
|
|
19
|
+
return { verified: false, warning: `No event found for hook "${expectedHook}"` };
|
|
20
|
+
}
|
|
21
|
+
if (matched.decision !== expectedDecision) {
|
|
22
|
+
return {
|
|
23
|
+
verified: false,
|
|
24
|
+
matchedEvent: matched,
|
|
25
|
+
warning: `Event logged decision="${matched.decision}" but expected "${expectedDecision}"`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { verified: true, matchedEvent: matched };
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HookEntry, BuildingBlock } from "../catalog/types.js";
|
|
2
|
+
export interface HookInput {
|
|
3
|
+
tool_name: string;
|
|
4
|
+
tool_input: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface HookResult {
|
|
7
|
+
decision: "block" | "allow";
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TestCase {
|
|
11
|
+
name: string;
|
|
12
|
+
category: string;
|
|
13
|
+
hookScript: string;
|
|
14
|
+
input: HookInput;
|
|
15
|
+
expectation: "block" | "allow";
|
|
16
|
+
setup?: (projectDir: string) => Promise<void>;
|
|
17
|
+
teardown?: (projectDir: string) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface TestResult {
|
|
20
|
+
testCase: TestCase;
|
|
21
|
+
actual: "block" | "allow";
|
|
22
|
+
passed: boolean;
|
|
23
|
+
reason?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function simulateHook(hookPath: string, input: HookInput, cwd?: string): Promise<HookResult>;
|
|
27
|
+
export declare function getRegisteredHooks(projectDir: string): Promise<{
|
|
28
|
+
event: string;
|
|
29
|
+
matcher: string;
|
|
30
|
+
command: string;
|
|
31
|
+
}[]>;
|
|
32
|
+
export declare function runTestCase(projectDir: string, testCase: TestCase): Promise<TestResult>;
|
|
33
|
+
export declare function generateBlockTestCases(hookEntries: HookEntry[], blocks: BuildingBlock[], currentBranch?: string, registeredHooks?: {
|
|
34
|
+
event: string;
|
|
35
|
+
matcher: string;
|
|
36
|
+
command: string;
|
|
37
|
+
}[]): TestCase[];
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyDefaults } from "../catalog/template-engine.js";
|
|
5
|
+
// 핵심 함수: hook 스크립트에 JSON stdin을 넣고 결과 파싱
|
|
6
|
+
export async function simulateHook(hookPath, input, cwd) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
child.kill();
|
|
10
|
+
resolve({ decision: "allow" });
|
|
11
|
+
}, 5000);
|
|
12
|
+
const child = spawn("bash", [hookPath], {
|
|
13
|
+
cwd,
|
|
14
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
15
|
+
});
|
|
16
|
+
let stdout = "";
|
|
17
|
+
child.stdout.on("data", (chunk) => {
|
|
18
|
+
stdout += chunk.toString();
|
|
19
|
+
});
|
|
20
|
+
child.on("close", () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
const trimmed = stdout.trim();
|
|
23
|
+
if (!trimmed) {
|
|
24
|
+
resolve({ decision: "allow" });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// stdout에서 {"decision":"block","reason":"..."} 찾기
|
|
28
|
+
const jsonMatch = trimmed.match(/\{[^}]*"decision"\s*:\s*"block"[^}]*\}/);
|
|
29
|
+
if (jsonMatch) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
32
|
+
resolve({ decision: "block", reason: parsed.reason });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
resolve({ decision: "allow" });
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
resolve({ decision: "allow" });
|
|
40
|
+
});
|
|
41
|
+
child.on("error", () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve({ decision: "allow" });
|
|
44
|
+
});
|
|
45
|
+
// EPIPE 방지: 자식 프로세스가 이미 종료된 경우 stdin 에러 무시
|
|
46
|
+
child.stdin.on("error", () => { });
|
|
47
|
+
child.stdin.write(JSON.stringify(input));
|
|
48
|
+
child.stdin.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// settings.json에서 등록된 hook 스크립트 목록 가져오기
|
|
52
|
+
export async function getRegisteredHooks(projectDir) {
|
|
53
|
+
const settingsPath = path.join(projectDir, ".claude", "settings.json");
|
|
54
|
+
const raw = await fs.readFile(settingsPath, "utf-8");
|
|
55
|
+
const settings = JSON.parse(raw);
|
|
56
|
+
const hooks = [];
|
|
57
|
+
for (const event of ["PreToolUse", "PostToolUse"]) {
|
|
58
|
+
const eventHooks = settings.hooks?.[event];
|
|
59
|
+
if (!Array.isArray(eventHooks))
|
|
60
|
+
continue;
|
|
61
|
+
for (const entry of eventHooks) {
|
|
62
|
+
const matcher = entry.matcher ?? "";
|
|
63
|
+
for (const hook of entry.hooks ?? []) {
|
|
64
|
+
if (hook.type === "command" && hook.command) {
|
|
65
|
+
hooks.push({ event, matcher, command: hook.command });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return hooks;
|
|
71
|
+
}
|
|
72
|
+
// 테스트 실행
|
|
73
|
+
export async function runTestCase(projectDir, testCase) {
|
|
74
|
+
const hookPath = path.join(projectDir, testCase.hookScript);
|
|
75
|
+
try {
|
|
76
|
+
await fs.access(hookPath);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
if (testCase.teardown)
|
|
80
|
+
await testCase.teardown(projectDir);
|
|
81
|
+
return {
|
|
82
|
+
testCase,
|
|
83
|
+
actual: "allow",
|
|
84
|
+
passed: false,
|
|
85
|
+
error: `Hook script not found: ${testCase.hookScript}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (testCase.setup)
|
|
89
|
+
await testCase.setup(projectDir);
|
|
90
|
+
try {
|
|
91
|
+
const result = await simulateHook(hookPath, testCase.input, projectDir);
|
|
92
|
+
const passed = result.decision === testCase.expectation;
|
|
93
|
+
return {
|
|
94
|
+
testCase,
|
|
95
|
+
actual: result.decision,
|
|
96
|
+
passed,
|
|
97
|
+
reason: result.reason,
|
|
98
|
+
error: passed ? undefined : `expected ${testCase.expectation} but got ${result.decision}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (testCase.teardown)
|
|
103
|
+
await testCase.teardown(projectDir);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 블록 기반 테스트 케이스 생성
|
|
107
|
+
export function generateBlockTestCases(hookEntries, blocks, currentBranch, registeredHooks) {
|
|
108
|
+
const cases = [];
|
|
109
|
+
for (const entry of hookEntries) {
|
|
110
|
+
const block = blocks.find((b) => b.id === entry.block);
|
|
111
|
+
if (!block)
|
|
112
|
+
continue;
|
|
113
|
+
if (!block.canBlock)
|
|
114
|
+
continue;
|
|
115
|
+
const params = applyDefaults(block, entry.params);
|
|
116
|
+
// Find actual script path from registered hooks, fallback to catalog- prefix
|
|
117
|
+
// Map block ids to enforcement script name patterns
|
|
118
|
+
const blockIdAliases = {
|
|
119
|
+
"path-guard": ["path-guard", "file-guard"],
|
|
120
|
+
"command-guard": ["command-guard"],
|
|
121
|
+
"branch-guard": ["branch-guard"],
|
|
122
|
+
"lockfile-guard": ["lockfile-guard"],
|
|
123
|
+
"secret-file-guard": ["secret-file-guard"],
|
|
124
|
+
"tdd-guard": ["tdd-guard"],
|
|
125
|
+
};
|
|
126
|
+
const aliases = blockIdAliases[block.id] ?? [block.id];
|
|
127
|
+
const matchedHook = registeredHooks?.find((h) => {
|
|
128
|
+
const scriptName = h.command.replace(/^bash\s+/, "");
|
|
129
|
+
return aliases.some((alias) => scriptName.includes(alias));
|
|
130
|
+
});
|
|
131
|
+
const hookScript = matchedHook
|
|
132
|
+
? matchedHook.command.replace(/^bash\s+/, "")
|
|
133
|
+
: `.claude/hooks/catalog-${block.id}.sh`;
|
|
134
|
+
switch (block.id) {
|
|
135
|
+
case "path-guard": {
|
|
136
|
+
const blockedPaths = params.blockedPaths ?? [];
|
|
137
|
+
for (const blocked of blockedPaths) {
|
|
138
|
+
const testPath = blocked.endsWith("/")
|
|
139
|
+
? `${blocked}test-file.js`
|
|
140
|
+
: blocked.startsWith("*")
|
|
141
|
+
? `test${blocked.slice(1)}`
|
|
142
|
+
: blocked;
|
|
143
|
+
cases.push({
|
|
144
|
+
name: `${testPath} → BLOCKED`,
|
|
145
|
+
category: "path-guard",
|
|
146
|
+
hookScript,
|
|
147
|
+
input: { tool_name: "Edit", tool_input: { file_path: testPath } },
|
|
148
|
+
expectation: "block",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
cases.push({
|
|
152
|
+
name: "src/index.ts → ALLOWED",
|
|
153
|
+
category: "path-guard",
|
|
154
|
+
hookScript,
|
|
155
|
+
input: { tool_name: "Edit", tool_input: { file_path: "src/index.ts" } },
|
|
156
|
+
expectation: "allow",
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "command-guard": {
|
|
161
|
+
const patterns = params.patterns ?? [];
|
|
162
|
+
for (const pattern of patterns) {
|
|
163
|
+
cases.push({
|
|
164
|
+
name: `"${pattern}" → BLOCKED`,
|
|
165
|
+
category: "command-guard",
|
|
166
|
+
hookScript,
|
|
167
|
+
input: { tool_name: "Bash", tool_input: { command: pattern } },
|
|
168
|
+
expectation: "block",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
cases.push({
|
|
172
|
+
name: '"npm test" → ALLOWED',
|
|
173
|
+
category: "command-guard",
|
|
174
|
+
hookScript,
|
|
175
|
+
input: { tool_name: "Bash", tool_input: { command: "npm test" } },
|
|
176
|
+
expectation: "allow",
|
|
177
|
+
});
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "branch-guard": {
|
|
181
|
+
const isProtected = currentBranch === "main" || currentBranch === "master";
|
|
182
|
+
cases.push({
|
|
183
|
+
name: `git commit on ${currentBranch ?? "unknown"} → ${isProtected ? "BLOCKED" : "ALLOWED"}`,
|
|
184
|
+
category: "branch-guard",
|
|
185
|
+
hookScript,
|
|
186
|
+
input: { tool_name: "Bash", tool_input: { command: "git commit -m 'test'" } },
|
|
187
|
+
expectation: isProtected ? "block" : "allow",
|
|
188
|
+
});
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "lockfile-guard": {
|
|
192
|
+
cases.push({
|
|
193
|
+
name: "package-lock.json → BLOCKED",
|
|
194
|
+
category: "lockfile-guard",
|
|
195
|
+
hookScript,
|
|
196
|
+
input: { tool_name: "Edit", tool_input: { file_path: "package-lock.json" } },
|
|
197
|
+
expectation: "block",
|
|
198
|
+
});
|
|
199
|
+
cases.push({
|
|
200
|
+
name: "package.json → ALLOWED",
|
|
201
|
+
category: "lockfile-guard",
|
|
202
|
+
hookScript,
|
|
203
|
+
input: { tool_name: "Edit", tool_input: { file_path: "package.json" } },
|
|
204
|
+
expectation: "allow",
|
|
205
|
+
});
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case "secret-file-guard": {
|
|
209
|
+
cases.push({
|
|
210
|
+
name: ".env → BLOCKED",
|
|
211
|
+
category: "secret-file-guard",
|
|
212
|
+
hookScript,
|
|
213
|
+
input: { tool_name: "Edit", tool_input: { file_path: ".env" } },
|
|
214
|
+
expectation: "block",
|
|
215
|
+
});
|
|
216
|
+
cases.push({
|
|
217
|
+
name: "src/app.ts → ALLOWED",
|
|
218
|
+
category: "secret-file-guard",
|
|
219
|
+
hookScript,
|
|
220
|
+
input: { tool_name: "Edit", tool_input: { file_path: "src/app.ts" } },
|
|
221
|
+
expectation: "allow",
|
|
222
|
+
});
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "tdd-guard": {
|
|
226
|
+
const stateFile = ".claude/hooks/.state/edit-history.json";
|
|
227
|
+
// block case: source file without prior test edit
|
|
228
|
+
cases.push({
|
|
229
|
+
name: "src/event-logger.ts without test → BLOCKED",
|
|
230
|
+
category: "tdd-guard",
|
|
231
|
+
hookScript,
|
|
232
|
+
input: { tool_name: "Edit", tool_input: { file_path: "src/event-logger.ts" } },
|
|
233
|
+
expectation: "block",
|
|
234
|
+
setup: async (projectDir) => {
|
|
235
|
+
const historyPath = path.join(projectDir, stateFile);
|
|
236
|
+
try {
|
|
237
|
+
await fs.unlink(historyPath);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// file may not exist
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
teardown: async (projectDir) => {
|
|
244
|
+
const historyPath = path.join(projectDir, stateFile);
|
|
245
|
+
try {
|
|
246
|
+
await fs.unlink(historyPath);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// file may not exist
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
// allow case: test file edit
|
|
254
|
+
cases.push({
|
|
255
|
+
name: "tests/unit/event-logger.test.ts → ALLOWED",
|
|
256
|
+
category: "tdd-guard",
|
|
257
|
+
hookScript,
|
|
258
|
+
input: { tool_name: "Edit", tool_input: { file_path: "tests/unit/event-logger.test.ts" } },
|
|
259
|
+
expectation: "allow",
|
|
260
|
+
setup: async (projectDir) => {
|
|
261
|
+
const historyPath = path.join(projectDir, stateFile);
|
|
262
|
+
const stateDir = path.dirname(historyPath);
|
|
263
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
264
|
+
await fs.writeFile(historyPath, JSON.stringify({ edits: [] }));
|
|
265
|
+
},
|
|
266
|
+
teardown: async (projectDir) => {
|
|
267
|
+
const historyPath = path.join(projectDir, stateFile);
|
|
268
|
+
try {
|
|
269
|
+
await fs.unlink(historyPath);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// file may not exist
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
// allow case: non-code file
|
|
277
|
+
cases.push({
|
|
278
|
+
name: "README.md → ALLOWED",
|
|
279
|
+
category: "tdd-guard",
|
|
280
|
+
hookScript,
|
|
281
|
+
input: { tool_name: "Edit", tool_input: { file_path: "README.md" } },
|
|
282
|
+
expectation: "allow",
|
|
283
|
+
});
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return cases;
|
|
289
|
+
}
|