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 +18 -0
- package/dist/catalog/converter.js +4 -3
- package/dist/catalog/template-engine.d.ts +1 -0
- package/dist/catalog/template-engine.js +9 -0
- package/dist/catalog/types.d.ts +2 -2
- package/dist/catalog/types.js +1 -1
- package/dist/cli/command-checker.d.ts +15 -0
- package/dist/cli/command-checker.js +104 -0
- package/dist/cli/commands/test.d.ts +12 -0
- package/dist/cli/commands/test.js +146 -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 +41 -0
- package/dist/cli/harness-tester.js +370 -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/core/harness-schema.d.ts +14 -14
- package/dist/core/harness-schema.js +1 -1
- package/dist/nl/prompt-templates.js +19 -8
- 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
|
|
@@ -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
|
|
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,
|
|
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) {
|
package/dist/catalog/types.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/catalog/types.js
CHANGED
|
@@ -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[];
|