oh-my-harness 0.4.1 → 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 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
@@ -1,5 +1,5 @@
1
1
  import path from "path";
2
- import { renderTemplate, validateParams } from "./template-engine.js";
2
+ import { renderTemplate, validateParams, applyDefaults } from "./template-engine.js";
3
3
  export async function convertHookEntries(entries, registry, projectDir) {
4
4
  const hooksConfig = {};
5
5
  const scripts = new Map();
@@ -11,7 +11,8 @@ export async function convertHookEntries(entries, registry, projectDir) {
11
11
  errors.push(`Unknown block id: "${entry.block}"`);
12
12
  continue;
13
13
  }
14
- const paramErrors = validateParams(block, entry.params);
14
+ const resolvedParams = applyDefaults(block, entry.params);
15
+ const paramErrors = validateParams(block, resolvedParams);
15
16
  if (paramErrors.length > 0) {
16
17
  errors.push(...paramErrors);
17
18
  continue;
@@ -23,7 +24,7 @@ export async function convertHookEntries(entries, registry, projectDir) {
23
24
  seenBlockIds.add(entry.block);
24
25
  let scriptContent;
25
26
  try {
26
- scriptContent = renderTemplate(block.template, entry.params);
27
+ scriptContent = renderTemplate(block.template, resolvedParams);
27
28
  }
28
29
  catch (err) {
29
30
  errors.push(`Failed to render block "${entry.block}": ${err.message}`);
@@ -1,3 +1,4 @@
1
1
  import type { BuildingBlock } from "./types.js";
2
2
  export declare function renderTemplate(template: string, params: Record<string, unknown>): string;
3
+ export declare function applyDefaults(block: BuildingBlock, params: Record<string, unknown>): Record<string, unknown>;
3
4
  export declare function validateParams(block: BuildingBlock, params: Record<string, unknown>): string[];
@@ -3,6 +3,15 @@ export function renderTemplate(template, params) {
3
3
  const compiled = Handlebars.compile(template);
4
4
  return compiled(params);
5
5
  }
6
+ export function applyDefaults(block, params) {
7
+ const result = { ...params };
8
+ for (const param of block.params) {
9
+ if (result[param.name] === undefined && param.default !== undefined) {
10
+ result[param.name] = param.default;
11
+ }
12
+ }
13
+ return result;
14
+ }
6
15
  export function validateParams(block, params) {
7
16
  const errors = [];
8
17
  for (const param of block.params) {
@@ -109,11 +109,11 @@ export declare const BuildingBlockSchema: z.ZodObject<{
109
109
  }>;
110
110
  export declare const HookEntrySchema: z.ZodObject<{
111
111
  block: z.ZodString;
112
- params: z.ZodRecord<z.ZodString, z.ZodUnknown>;
112
+ params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
113
113
  }, "strip", z.ZodTypeAny, {
114
114
  params: Record<string, unknown>;
115
115
  block: string;
116
116
  }, {
117
- params: Record<string, unknown>;
118
117
  block: string;
118
+ params?: Record<string, unknown> | undefined;
119
119
  }>;
@@ -20,5 +20,5 @@ export const BuildingBlockSchema = z.object({
20
20
  });
21
21
  export const HookEntrySchema = z.object({
22
22
  block: z.string(),
23
- params: z.record(z.unknown()),
23
+ params: z.record(z.unknown()).default({}),
24
24
  });
@@ -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
+ }
@@ -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,146 @@
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, generateTestCases, generateBlockTestCases, runTestCase } from "../harness-tester.js";
7
+ import { checkHarnessCommands } from "../command-checker.js";
8
+ import { HarnessConfigSchema } from "../../core/harness-schema.js";
9
+ import { builtinBlocks } from "../../catalog/blocks/index.js";
10
+ export function formatCategoryName(category) {
11
+ const names = {
12
+ "path-guard": "File guards",
13
+ "command-guard": "Command guards",
14
+ "branch-guard": "Branch guard",
15
+ "lockfile-guard": "Lockfile guard",
16
+ "secret-file-guard": "Secret file guard",
17
+ "commit-test-gate": "Pre-commit test gate",
18
+ "commit-typecheck-gate": "Pre-commit typecheck gate",
19
+ "lint-on-save": "Lint on save",
20
+ "format-on-save": "Format on save",
21
+ "auto-pr": "Auto PR",
22
+ "tdd-guard": "TDD Guard",
23
+ };
24
+ return names[category] ?? category;
25
+ }
26
+ export async function testCommand(options = {}) {
27
+ const projectDir = options.projectDir ?? process.cwd();
28
+ // 1. harness.yaml 읽기
29
+ const harnessPath = path.join(projectDir, "harness.yaml");
30
+ let enforcement = { blockedPaths: [], blockedCommands: [] };
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
+ enforcement = {
38
+ blockedPaths: result.data.enforcement.blockedPaths,
39
+ blockedCommands: result.data.enforcement.blockedCommands,
40
+ };
41
+ hookEntries = result.data.hooks ?? [];
42
+ }
43
+ else {
44
+ console.log(chalk.yellow(`Warning: harness.yaml schema validation failed: ${result.error.message}`));
45
+ }
46
+ }
47
+ catch (err) {
48
+ const error = err;
49
+ if (error.code !== "ENOENT") {
50
+ console.log(chalk.red(`Error reading harness.yaml: ${error.message}`));
51
+ process.exit(1);
52
+ }
53
+ // ENOENT: harness.yaml 없으면 settings.json만으로 진행
54
+ }
55
+ // 2. settings.json에서 등록된 hook 가져오기
56
+ let hooks;
57
+ try {
58
+ hooks = await getRegisteredHooks(projectDir);
59
+ }
60
+ catch (err) {
61
+ const error = err;
62
+ if (error.code === "ENOENT") {
63
+ console.log(chalk.red("harness not initialized. Run `omh init` first."));
64
+ }
65
+ else {
66
+ console.log(chalk.red(`Failed to read settings.json: ${error.message}`));
67
+ }
68
+ process.exit(1);
69
+ }
70
+ if (hooks.length === 0) {
71
+ console.log(chalk.yellow("No hooks registered in settings.json."));
72
+ return { passed: 0, failed: 0, results: [], commandResults: [] };
73
+ }
74
+ // 3. 시뮬레이션 테스트 케이스 생성 + 실행
75
+ p.intro(`${chalk.bgCyan(chalk.black(" omh test "))} ${chalk.dim("Harness dry-run verification")}`);
76
+ // 현재 브랜치 가져오기
77
+ let currentBranch;
78
+ try {
79
+ const { execFile: execFileCb } = await import("node:child_process");
80
+ const { promisify: promisifyFn } = await import("node:util");
81
+ const execFileAsync = promisifyFn(execFileCb);
82
+ const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { cwd: projectDir });
83
+ currentBranch = stdout.trim() || undefined;
84
+ }
85
+ catch {
86
+ // git 없으면 undefined
87
+ }
88
+ const enforcementCases = generateTestCases(hooks, enforcement, currentBranch);
89
+ const blockCases = generateBlockTestCases(hookEntries, builtinBlocks, currentBranch);
90
+ // Block cases take priority — only keep enforcement cases for categories not covered by blocks
91
+ const blockCategories = new Set(blockCases.map((c) => c.category));
92
+ const uniqueEnforcementCases = enforcementCases.filter((c) => !blockCategories.has(c.category));
93
+ const testCases = [...uniqueEnforcementCases, ...blockCases];
94
+ const results = [];
95
+ // 카테고리별 그룹핑
96
+ const categories = [...new Set(testCases.map((tc) => tc.category))];
97
+ for (const category of categories) {
98
+ const casesInCategory = testCases.filter((tc) => tc.category === category);
99
+ const categoryResults = [];
100
+ for (const tc of casesInCategory) {
101
+ const result = await runTestCase(projectDir, tc);
102
+ categoryResults.push(result);
103
+ results.push(result);
104
+ }
105
+ // 카테고리 출력
106
+ const lines = categoryResults.map((r) => {
107
+ if (r.passed) {
108
+ return ` ${chalk.green("✓")} ${r.testCase.name}`;
109
+ }
110
+ else {
111
+ const hint = r.error ?? `expected ${r.testCase.expectation} but got ${r.actual}`;
112
+ return ` ${chalk.red("✗")} ${r.testCase.name}\n ${chalk.dim(hint)}`;
113
+ }
114
+ });
115
+ p.note(lines.join("\n"), formatCategoryName(category));
116
+ }
117
+ // 4. 명령어 실행 가능 체크
118
+ const commandResults = await checkHarnessCommands(hooks, projectDir);
119
+ if (commandResults.length > 0) {
120
+ const cmdLines = commandResults.map((r) => {
121
+ if (r.executable) {
122
+ return ` ${chalk.green("✓")} ${r.command} — executable`;
123
+ }
124
+ else {
125
+ return ` ${chalk.red("✗")} ${r.command} — ${chalk.red("not found")}`;
126
+ }
127
+ });
128
+ p.note(cmdLines.join("\n"), "Command checks");
129
+ }
130
+ // 5. 결과 요약
131
+ const hookPassed = results.filter((r) => r.passed).length;
132
+ const hookFailed = results.filter((r) => !r.passed).length;
133
+ const cmdPassed = commandResults.filter((r) => r.executable).length;
134
+ const cmdFailed = commandResults.filter((r) => !r.executable).length;
135
+ const totalPassed = hookPassed + cmdPassed;
136
+ const totalFailed = hookFailed + cmdFailed;
137
+ const total = totalPassed + totalFailed;
138
+ if (totalFailed === 0) {
139
+ p.outro(chalk.green(`${totalPassed}/${total} checks passed ✓`));
140
+ }
141
+ else {
142
+ p.outro(chalk.red(`${totalFailed}/${total} checks failed`));
143
+ process.exit(1);
144
+ }
145
+ return { passed: totalPassed, failed: totalFailed, results, commandResults };
146
+ }
@@ -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,41 @@
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 generateTestCases(hooks: {
33
+ event: string;
34
+ matcher: string;
35
+ command: string;
36
+ }[], enforcement: {
37
+ blockedPaths?: string[];
38
+ blockedCommands?: string[];
39
+ }, currentBranch?: string): TestCase[];
40
+ export declare function runTestCase(projectDir: string, testCase: TestCase): Promise<TestResult>;
41
+ export declare function generateBlockTestCases(hookEntries: HookEntry[], blocks: BuildingBlock[], currentBranch?: string): TestCase[];