@hasna/hooks 0.0.7 → 0.1.1
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/bin/index.js +240 -42
- package/dist/index.js +228 -30
- package/hooks/hook-autoformat/README.md +39 -0
- package/hooks/hook-autoformat/package.json +58 -0
- package/hooks/hook-autoformat/src/hook.ts +223 -0
- package/hooks/hook-autostage/README.md +70 -0
- package/hooks/hook-autostage/package.json +12 -0
- package/hooks/hook-autostage/src/hook.ts +167 -0
- package/hooks/hook-commandlog/README.md +45 -0
- package/hooks/hook-commandlog/package.json +12 -0
- package/hooks/hook-commandlog/src/hook.ts +92 -0
- package/hooks/hook-costwatch/README.md +61 -0
- package/hooks/hook-costwatch/package.json +12 -0
- package/hooks/hook-costwatch/src/hook.ts +178 -0
- package/hooks/hook-desktopnotify/README.md +50 -0
- package/hooks/hook-desktopnotify/package.json +57 -0
- package/hooks/hook-desktopnotify/src/hook.ts +112 -0
- package/hooks/hook-envsetup/README.md +40 -0
- package/hooks/hook-envsetup/package.json +58 -0
- package/hooks/hook-envsetup/src/hook.ts +197 -0
- package/hooks/hook-errornotify/README.md +66 -0
- package/hooks/hook-errornotify/package.json +12 -0
- package/hooks/hook-errornotify/src/hook.ts +197 -0
- package/hooks/hook-permissionguard/README.md +48 -0
- package/hooks/hook-permissionguard/package.json +58 -0
- package/hooks/hook-permissionguard/src/hook.ts +268 -0
- package/hooks/hook-promptguard/README.md +64 -0
- package/hooks/hook-promptguard/package.json +12 -0
- package/hooks/hook-promptguard/src/hook.ts +200 -0
- package/hooks/hook-protectfiles/README.md +62 -0
- package/hooks/hook-protectfiles/package.json +58 -0
- package/hooks/hook-protectfiles/src/hook.ts +267 -0
- package/hooks/hook-sessionlog/README.md +48 -0
- package/hooks/hook-sessionlog/package.json +12 -0
- package/hooks/hook-sessionlog/src/hook.ts +100 -0
- package/hooks/hook-slacknotify/README.md +62 -0
- package/hooks/hook-slacknotify/package.json +12 -0
- package/hooks/hook-slacknotify/src/hook.ts +146 -0
- package/hooks/hook-soundnotify/README.md +63 -0
- package/hooks/hook-soundnotify/package.json +12 -0
- package/hooks/hook-soundnotify/src/hook.ts +173 -0
- package/hooks/hook-taskgate/README.md +62 -0
- package/hooks/hook-taskgate/package.json +12 -0
- package/hooks/hook-taskgate/src/hook.ts +169 -0
- package/hooks/hook-tddguard/README.md +50 -0
- package/hooks/hook-tddguard/package.json +12 -0
- package/hooks/hook-tddguard/src/hook.ts +263 -0
- package/package.json +3 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: taskgate
|
|
5
|
+
*
|
|
6
|
+
* TaskCompleted hook that validates a task is actually complete before
|
|
7
|
+
* allowing it to be marked done. Lightweight gate designed to be
|
|
8
|
+
* extended by users with custom validation logic.
|
|
9
|
+
*
|
|
10
|
+
* Current checks:
|
|
11
|
+
* - If task mentions "test" or "tests", verifies test files exist in cwd
|
|
12
|
+
* - If task mentions "lint" or "format", approves (can't verify externally)
|
|
13
|
+
* - For all other tasks, approves by default
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
|
|
19
|
+
interface HookInput {
|
|
20
|
+
session_id: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
tool_name: string;
|
|
23
|
+
tool_input: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface HookOutput {
|
|
27
|
+
decision: "approve" | "block";
|
|
28
|
+
reason?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read and parse JSON from stdin
|
|
33
|
+
*/
|
|
34
|
+
function readStdinJson(): HookInput | null {
|
|
35
|
+
try {
|
|
36
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
37
|
+
if (!input) return null;
|
|
38
|
+
return JSON.parse(input);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recursively check if any test files exist in a directory
|
|
46
|
+
* Looks for common test file patterns: *.test.*, *.spec.*, test_*, *_test.*
|
|
47
|
+
*/
|
|
48
|
+
function hasTestFiles(dir: string, depth: number = 0): boolean {
|
|
49
|
+
if (depth > 4) return false; // Don't recurse too deep
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
53
|
+
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const name = entry.name;
|
|
56
|
+
|
|
57
|
+
// Skip node_modules, .git, dist, build, etc.
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"].includes(name)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check common test directories
|
|
64
|
+
if (["test", "tests", "__tests__", "spec", "specs"].includes(name)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Recurse into subdirectories
|
|
69
|
+
if (hasTestFiles(join(dir, name), depth + 1)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check test file patterns
|
|
75
|
+
if (entry.isFile()) {
|
|
76
|
+
const lower = name.toLowerCase();
|
|
77
|
+
if (
|
|
78
|
+
lower.includes(".test.") ||
|
|
79
|
+
lower.includes(".spec.") ||
|
|
80
|
+
lower.startsWith("test_") ||
|
|
81
|
+
lower.endsWith("_test.py") ||
|
|
82
|
+
lower.endsWith("_test.go") ||
|
|
83
|
+
lower.endsWith("_test.ts") ||
|
|
84
|
+
lower.endsWith("_test.js")
|
|
85
|
+
) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Directory read failed — can't verify, so don't block
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract task description from tool_input
|
|
100
|
+
*/
|
|
101
|
+
function getTaskDescription(toolInput: Record<string, unknown>): string {
|
|
102
|
+
// Try common field names for task description
|
|
103
|
+
const candidates = [
|
|
104
|
+
toolInput.description,
|
|
105
|
+
toolInput.task,
|
|
106
|
+
toolInput.title,
|
|
107
|
+
toolInput.summary,
|
|
108
|
+
toolInput.content,
|
|
109
|
+
toolInput.text,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
if (candidate && typeof candidate === "string") {
|
|
114
|
+
return candidate;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Fallback: stringify the whole input
|
|
119
|
+
return JSON.stringify(toolInput).toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Output hook response
|
|
124
|
+
*/
|
|
125
|
+
function respond(output: HookOutput): void {
|
|
126
|
+
console.log(JSON.stringify(output));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Main hook execution
|
|
131
|
+
*/
|
|
132
|
+
export function run(): void {
|
|
133
|
+
const input = readStdinJson();
|
|
134
|
+
|
|
135
|
+
if (!input) {
|
|
136
|
+
respond({ decision: "approve" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const description = getTaskDescription(input.tool_input || {}).toLowerCase();
|
|
141
|
+
const cwd = input.cwd;
|
|
142
|
+
|
|
143
|
+
// Check: if the task mentions tests, verify test files exist
|
|
144
|
+
if (/\btests?\b/.test(description)) {
|
|
145
|
+
if (!hasTestFiles(cwd)) {
|
|
146
|
+
console.error("[hook-taskgate] Task mentions tests but no test files found in project");
|
|
147
|
+
respond({
|
|
148
|
+
decision: "block",
|
|
149
|
+
reason: "Task mentions tests but no test files were found in the project. Please create test files before marking this task as complete.",
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.error("[hook-taskgate] Task mentions tests — test files found, approved");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check: if the task mentions lint/format, approve (can't verify externally)
|
|
157
|
+
if (/\b(lint|linting|format|formatting)\b/.test(description)) {
|
|
158
|
+
console.error("[hook-taskgate] Task mentions lint/format — approved (cannot verify externally)");
|
|
159
|
+
respond({ decision: "approve" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Default: approve all other tasks
|
|
164
|
+
respond({ decision: "approve" });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (import.meta.main) {
|
|
168
|
+
run();
|
|
169
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# hook-tddguard
|
|
2
|
+
|
|
3
|
+
Claude Code hook that enforces Test-Driven Development by blocking implementation file edits unless a corresponding test file exists.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Before allowing edits to implementation files, this hook checks whether a corresponding test file exists. If no test file is found, the edit is blocked with a message to write tests first.
|
|
8
|
+
|
|
9
|
+
## Event
|
|
10
|
+
|
|
11
|
+
- **PreToolUse** (matcher: `Edit|Write`)
|
|
12
|
+
|
|
13
|
+
## Behavior
|
|
14
|
+
|
|
15
|
+
- **Test files** (`*.test.ts`, `*.spec.ts`, `*_test.py`, `test_*.py`, `*_test.go`) are always approved
|
|
16
|
+
- **Config files** (`*.json`, `*.md`, `*.yml`, `*.yaml`, `*.toml`, `*.css`, `*.html`) are always approved
|
|
17
|
+
- **Implementation files** are checked for a corresponding test file:
|
|
18
|
+
- Same directory: `foo.test.ts`, `foo.spec.ts`
|
|
19
|
+
- `__tests__/` subdirectory
|
|
20
|
+
- `tests/` subdirectory
|
|
21
|
+
- Python: `test_foo.py`, `foo_test.py`
|
|
22
|
+
- Go: `foo_test.go`
|
|
23
|
+
- If no test file exists, the edit is **blocked**
|
|
24
|
+
|
|
25
|
+
## Supported Languages
|
|
26
|
+
|
|
27
|
+
| Language | Test File Patterns |
|
|
28
|
+
|----------|--------------------|
|
|
29
|
+
| TypeScript/JavaScript | `*.test.ts`, `*.spec.ts`, `*.test.js`, `*.spec.js` |
|
|
30
|
+
| Python | `test_*.py`, `*_test.py` |
|
|
31
|
+
| Go | `*_test.go` |
|
|
32
|
+
| Java | `*Test.java` |
|
|
33
|
+
| Ruby | `*_test.rb`, `*_spec.rb` |
|
|
34
|
+
|
|
35
|
+
## Example
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
# Editing src/utils.ts without src/utils.test.ts existing:
|
|
39
|
+
# → BLOCKED: "Write tests first (TDD). No test file found for utils.ts."
|
|
40
|
+
|
|
41
|
+
# Editing src/utils.test.ts:
|
|
42
|
+
# → APPROVED (always)
|
|
43
|
+
|
|
44
|
+
# Editing src/utils.ts WITH src/utils.test.ts existing:
|
|
45
|
+
# → APPROVED
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-tddguard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that enforces TDD by blocking implementation edits without corresponding test files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "MIT"
|
|
12
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: tddguard
|
|
5
|
+
*
|
|
6
|
+
* PreToolUse hook that enforces TDD by blocking implementation file edits
|
|
7
|
+
* unless a corresponding test file exists in the project.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Test files (*.test.ts, *.spec.ts, *_test.py, test_*.py, *_test.go) → always approve
|
|
11
|
+
* - Config/non-code files (*.json, *.md, *.yml, etc.) → always approve
|
|
12
|
+
* - Implementation files → check if a corresponding test file exists; block if not
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from "fs";
|
|
16
|
+
import { basename, dirname, join, extname } from "path";
|
|
17
|
+
|
|
18
|
+
interface HookInput {
|
|
19
|
+
session_id: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
tool_name: string;
|
|
22
|
+
tool_input: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface HookOutput {
|
|
26
|
+
decision: "approve" | "block";
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** File extensions that never need tests */
|
|
31
|
+
const SKIP_EXTENSIONS = new Set([
|
|
32
|
+
".json",
|
|
33
|
+
".md",
|
|
34
|
+
".yml",
|
|
35
|
+
".yaml",
|
|
36
|
+
".toml",
|
|
37
|
+
".css",
|
|
38
|
+
".scss",
|
|
39
|
+
".less",
|
|
40
|
+
".html",
|
|
41
|
+
".svg",
|
|
42
|
+
".png",
|
|
43
|
+
".jpg",
|
|
44
|
+
".jpeg",
|
|
45
|
+
".gif",
|
|
46
|
+
".ico",
|
|
47
|
+
".lock",
|
|
48
|
+
".txt",
|
|
49
|
+
".env",
|
|
50
|
+
".gitignore",
|
|
51
|
+
".dockerignore",
|
|
52
|
+
".editorconfig",
|
|
53
|
+
".prettierrc",
|
|
54
|
+
".eslintrc",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** File basenames that never need tests */
|
|
58
|
+
const SKIP_BASENAMES = new Set([
|
|
59
|
+
"package.json",
|
|
60
|
+
"tsconfig.json",
|
|
61
|
+
"biome.json",
|
|
62
|
+
"vite.config.ts",
|
|
63
|
+
"vite.config.js",
|
|
64
|
+
"next.config.js",
|
|
65
|
+
"next.config.mjs",
|
|
66
|
+
"next.config.ts",
|
|
67
|
+
"tailwind.config.js",
|
|
68
|
+
"tailwind.config.ts",
|
|
69
|
+
"postcss.config.js",
|
|
70
|
+
"postcss.config.mjs",
|
|
71
|
+
"jest.config.ts",
|
|
72
|
+
"jest.config.js",
|
|
73
|
+
"vitest.config.ts",
|
|
74
|
+
"vitest.config.js",
|
|
75
|
+
"Makefile",
|
|
76
|
+
"Dockerfile",
|
|
77
|
+
"docker-compose.yml",
|
|
78
|
+
"docker-compose.yaml",
|
|
79
|
+
".babelrc",
|
|
80
|
+
".env.example",
|
|
81
|
+
".env.local",
|
|
82
|
+
"CLAUDE.md",
|
|
83
|
+
"README.md",
|
|
84
|
+
"LICENSE",
|
|
85
|
+
"CHANGELOG.md",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** Patterns that identify a test file */
|
|
89
|
+
const TEST_FILE_PATTERNS: RegExp[] = [
|
|
90
|
+
/\.test\.[jt]sx?$/, // *.test.ts, *.test.js, *.test.tsx, *.test.jsx
|
|
91
|
+
/\.spec\.[jt]sx?$/, // *.spec.ts, *.spec.js, *.spec.tsx, *.spec.jsx
|
|
92
|
+
/_test\.py$/, // *_test.py
|
|
93
|
+
/^test_.*\.py$/, // test_*.py
|
|
94
|
+
/_test\.go$/, // *_test.go
|
|
95
|
+
/\.test\.go$/, // *.test.go (less common but valid)
|
|
96
|
+
/Test\.java$/, // *Test.java
|
|
97
|
+
/_test\.rb$/, // *_test.rb
|
|
98
|
+
/\.test\.rb$/, // *.test.rb
|
|
99
|
+
/_spec\.rb$/, // *_spec.rb
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function readStdinJson(): HookInput | null {
|
|
103
|
+
try {
|
|
104
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
105
|
+
if (!input) return null;
|
|
106
|
+
return JSON.parse(input);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function respond(output: HookOutput): void {
|
|
113
|
+
console.log(JSON.stringify(output));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isTestFile(filePath: string): boolean {
|
|
117
|
+
const name = basename(filePath);
|
|
118
|
+
return TEST_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shouldSkipFile(filePath: string): boolean {
|
|
122
|
+
const name = basename(filePath);
|
|
123
|
+
const ext = extname(filePath).toLowerCase();
|
|
124
|
+
|
|
125
|
+
// Skip config and non-code files
|
|
126
|
+
if (SKIP_EXTENSIONS.has(ext)) return true;
|
|
127
|
+
if (SKIP_BASENAMES.has(name)) return true;
|
|
128
|
+
|
|
129
|
+
// Skip files in common config/non-code directories
|
|
130
|
+
const lowerPath = filePath.toLowerCase();
|
|
131
|
+
if (lowerPath.includes("/node_modules/")) return true;
|
|
132
|
+
if (lowerPath.includes("/.claude/")) return true;
|
|
133
|
+
if (lowerPath.includes("/dist/")) return true;
|
|
134
|
+
if (lowerPath.includes("/build/")) return true;
|
|
135
|
+
if (lowerPath.includes("/.git/")) return true;
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate possible test file paths for a given implementation file.
|
|
142
|
+
* Checks the same directory, __tests__/, tests/, and test/ subdirectories.
|
|
143
|
+
*/
|
|
144
|
+
function getPossibleTestPaths(filePath: string, cwd: string): string[] {
|
|
145
|
+
const dir = dirname(filePath);
|
|
146
|
+
const name = basename(filePath);
|
|
147
|
+
const ext = extname(filePath);
|
|
148
|
+
const nameWithoutExt = name.slice(0, name.length - ext.length);
|
|
149
|
+
|
|
150
|
+
const testPaths: string[] = [];
|
|
151
|
+
const resolvedDir = filePath.startsWith("/") ? dir : join(cwd, dir);
|
|
152
|
+
|
|
153
|
+
// TypeScript/JavaScript patterns
|
|
154
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
155
|
+
const testExts = ext.includes("x") ? [ext] : [ext];
|
|
156
|
+
for (const testExt of testExts) {
|
|
157
|
+
// Same directory: foo.test.ts, foo.spec.ts
|
|
158
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}.test${testExt}`));
|
|
159
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}.spec${testExt}`));
|
|
160
|
+
// __tests__/ directory
|
|
161
|
+
testPaths.push(join(resolvedDir, "__tests__", `${nameWithoutExt}.test${testExt}`));
|
|
162
|
+
testPaths.push(join(resolvedDir, "__tests__", `${nameWithoutExt}.spec${testExt}`));
|
|
163
|
+
// tests/ directory (sibling)
|
|
164
|
+
testPaths.push(join(resolvedDir, "tests", `${nameWithoutExt}.test${testExt}`));
|
|
165
|
+
testPaths.push(join(resolvedDir, "tests", `${nameWithoutExt}.spec${testExt}`));
|
|
166
|
+
// Parent __tests__/
|
|
167
|
+
testPaths.push(join(resolvedDir, "..", "__tests__", `${nameWithoutExt}.test${testExt}`));
|
|
168
|
+
testPaths.push(join(resolvedDir, "..", "__tests__", `${nameWithoutExt}.spec${testExt}`));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Python patterns
|
|
173
|
+
if (ext === ".py") {
|
|
174
|
+
// Same directory: test_foo.py, foo_test.py
|
|
175
|
+
testPaths.push(join(resolvedDir, `test_${name}`));
|
|
176
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_test.py`));
|
|
177
|
+
// tests/ directory
|
|
178
|
+
testPaths.push(join(resolvedDir, "tests", `test_${name}`));
|
|
179
|
+
testPaths.push(join(resolvedDir, "tests", `${nameWithoutExt}_test.py`));
|
|
180
|
+
// Parent tests/
|
|
181
|
+
testPaths.push(join(resolvedDir, "..", "tests", `test_${name}`));
|
|
182
|
+
testPaths.push(join(resolvedDir, "..", "tests", `${nameWithoutExt}_test.py`));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Go patterns
|
|
186
|
+
if (ext === ".go") {
|
|
187
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_test.go`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Java patterns
|
|
191
|
+
if (ext === ".java") {
|
|
192
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}Test.java`));
|
|
193
|
+
// Common Maven/Gradle structure: src/test/java mirrors src/main/java
|
|
194
|
+
const testDir = resolvedDir.replace("/src/main/", "/src/test/");
|
|
195
|
+
if (testDir !== resolvedDir) {
|
|
196
|
+
testPaths.push(join(testDir, `${nameWithoutExt}Test.java`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Ruby patterns
|
|
201
|
+
if (ext === ".rb") {
|
|
202
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_test.rb`));
|
|
203
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_spec.rb`));
|
|
204
|
+
testPaths.push(join(resolvedDir, "test", `${nameWithoutExt}_test.rb`));
|
|
205
|
+
testPaths.push(join(resolvedDir, "spec", `${nameWithoutExt}_spec.rb`));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return testPaths;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasCorrespondingTest(filePath: string, cwd: string): boolean {
|
|
212
|
+
const possiblePaths = getPossibleTestPaths(filePath, cwd);
|
|
213
|
+
return possiblePaths.some((testPath) => existsSync(testPath));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getFilePath(toolInput: Record<string, unknown>): string | null {
|
|
217
|
+
return (toolInput.file_path as string) || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function run(): void {
|
|
221
|
+
const input = readStdinJson();
|
|
222
|
+
|
|
223
|
+
if (!input) {
|
|
224
|
+
respond({ decision: "approve" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const filePath = getFilePath(input.tool_input);
|
|
229
|
+
if (!filePath) {
|
|
230
|
+
respond({ decision: "approve" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Always approve test files
|
|
235
|
+
if (isTestFile(filePath)) {
|
|
236
|
+
respond({ decision: "approve" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Skip files that don't need tests
|
|
241
|
+
if (shouldSkipFile(filePath)) {
|
|
242
|
+
respond({ decision: "approve" });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Check if a corresponding test file exists
|
|
247
|
+
if (hasCorrespondingTest(filePath, input.cwd)) {
|
|
248
|
+
respond({ decision: "approve" });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// No test file found — block the edit
|
|
253
|
+
const name = basename(filePath);
|
|
254
|
+
console.error(`[hook-tddguard] No test file found for ${name}. Write tests first (TDD).`);
|
|
255
|
+
respond({
|
|
256
|
+
decision: "block",
|
|
257
|
+
reason: `Write tests first (TDD). No test file found for "${name}". Create a test file before editing implementation code.`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (import.meta.main) {
|
|
262
|
+
run();
|
|
263
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Open source
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"hooks": "bin/index.js"
|
|
@@ -58,6 +58,6 @@
|
|
|
58
58
|
},
|
|
59
59
|
"repository": {
|
|
60
60
|
"type": "git",
|
|
61
|
-
"url": "git+https://github.com/hasna/
|
|
61
|
+
"url": "git+https://github.com/hasna/hooks.git"
|
|
62
62
|
}
|
|
63
63
|
}
|