@soleri/cli 9.4.0 → 9.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.
Files changed (64) hide show
  1. package/dist/commands/hooks.js +126 -0
  2. package/dist/commands/hooks.js.map +1 -1
  3. package/dist/commands/install.js +5 -0
  4. package/dist/commands/install.js.map +1 -1
  5. package/dist/hook-packs/converter/README.md +99 -0
  6. package/dist/hook-packs/converter/template.d.ts +36 -0
  7. package/dist/hook-packs/converter/template.js +127 -0
  8. package/dist/hook-packs/converter/template.js.map +1 -0
  9. package/dist/hook-packs/converter/template.test.ts +133 -0
  10. package/dist/hook-packs/converter/template.ts +163 -0
  11. package/dist/hook-packs/flock-guard/README.md +65 -0
  12. package/dist/hook-packs/flock-guard/manifest.json +36 -0
  13. package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  14. package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  15. package/dist/hook-packs/full/manifest.json +8 -1
  16. package/dist/hook-packs/graduation.d.ts +11 -0
  17. package/dist/hook-packs/graduation.js +48 -0
  18. package/dist/hook-packs/graduation.js.map +1 -0
  19. package/dist/hook-packs/graduation.ts +65 -0
  20. package/dist/hook-packs/installer.js +3 -1
  21. package/dist/hook-packs/installer.js.map +1 -1
  22. package/dist/hook-packs/installer.ts +3 -1
  23. package/dist/hook-packs/marketing-research/README.md +37 -0
  24. package/dist/hook-packs/marketing-research/manifest.json +24 -0
  25. package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  26. package/dist/hook-packs/registry.d.ts +1 -0
  27. package/dist/hook-packs/registry.js +14 -4
  28. package/dist/hook-packs/registry.js.map +1 -1
  29. package/dist/hook-packs/registry.ts +18 -4
  30. package/dist/hook-packs/safety/README.md +50 -0
  31. package/dist/hook-packs/safety/manifest.json +23 -0
  32. package/{src/hook-packs/yolo-safety → dist/hook-packs/safety}/scripts/anti-deletion.sh +7 -1
  33. package/dist/hook-packs/validator.d.ts +32 -0
  34. package/dist/hook-packs/validator.js +126 -0
  35. package/dist/hook-packs/validator.js.map +1 -0
  36. package/dist/hook-packs/validator.ts +158 -0
  37. package/dist/hook-packs/yolo-safety/manifest.json +3 -19
  38. package/package.json +1 -1
  39. package/src/__tests__/flock-guard.test.ts +225 -0
  40. package/src/__tests__/graduation.test.ts +199 -0
  41. package/src/__tests__/hook-packs.test.ts +44 -19
  42. package/src/__tests__/hooks-convert.test.ts +342 -0
  43. package/src/__tests__/validator.test.ts +265 -0
  44. package/src/commands/hooks.ts +172 -0
  45. package/src/commands/install.ts +6 -0
  46. package/src/hook-packs/converter/README.md +99 -0
  47. package/src/hook-packs/converter/template.test.ts +133 -0
  48. package/src/hook-packs/converter/template.ts +163 -0
  49. package/src/hook-packs/flock-guard/README.md +65 -0
  50. package/src/hook-packs/flock-guard/manifest.json +36 -0
  51. package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  52. package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  53. package/src/hook-packs/full/manifest.json +8 -1
  54. package/src/hook-packs/graduation.ts +65 -0
  55. package/src/hook-packs/installer.ts +3 -1
  56. package/src/hook-packs/marketing-research/README.md +37 -0
  57. package/src/hook-packs/marketing-research/manifest.json +24 -0
  58. package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  59. package/src/hook-packs/registry.ts +18 -4
  60. package/src/hook-packs/safety/README.md +50 -0
  61. package/src/hook-packs/safety/manifest.json +23 -0
  62. package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  63. package/src/hook-packs/validator.ts +158 -0
  64. package/src/hook-packs/yolo-safety/manifest.json +3 -19
@@ -0,0 +1,342 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ generateHookScript,
7
+ generateManifest,
8
+ HOOK_EVENTS,
9
+ ACTION_LEVELS,
10
+ } from '../hook-packs/converter/template.js';
11
+ import type { HookConversionConfig } from '../hook-packs/converter/template.js';
12
+
13
+ const tempDir = join(tmpdir(), `hooks-convert-test-${Date.now()}`);
14
+
15
+ describe('hooks convert', () => {
16
+ beforeEach(() => {
17
+ mkdirSync(tempDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe('generateManifest', () => {
25
+ it('should produce valid JSON with correct fields', () => {
26
+ const config: HookConversionConfig = {
27
+ name: 'brand-voice',
28
+ event: 'PreToolUse',
29
+ toolMatcher: 'Write|Edit',
30
+ action: 'remind',
31
+ message: 'Follow brand voice guidelines',
32
+ };
33
+
34
+ const manifest = generateManifest(config);
35
+
36
+ expect(manifest.name).toBe('brand-voice');
37
+ expect(manifest.version).toBe('1.0.0');
38
+ expect(manifest.description).toBe('Follow brand voice guidelines');
39
+ expect(manifest.hooks).toEqual([]);
40
+ expect(manifest.scripts).toHaveLength(1);
41
+ expect(manifest.scripts![0].name).toBe('brand-voice');
42
+ expect(manifest.scripts![0].file).toBe('brand-voice.sh');
43
+ expect(manifest.scripts![0].targetDir).toBe('hooks');
44
+ expect(manifest.lifecycleHooks).toHaveLength(1);
45
+ expect(manifest.lifecycleHooks![0].event).toBe('PreToolUse');
46
+ expect(manifest.lifecycleHooks![0].matcher).toBe('Write|Edit');
47
+ expect(manifest.actionLevel).toBe('remind');
48
+
49
+ // Verify it serializes to valid JSON
50
+ const json = JSON.stringify(manifest);
51
+ expect(() => JSON.parse(json)).not.toThrow();
52
+ });
53
+
54
+ it('should use message as description when no description provided', () => {
55
+ const config: HookConversionConfig = {
56
+ name: 'test-hook',
57
+ event: 'PreCompact',
58
+ action: 'warn',
59
+ message: 'Save session state',
60
+ };
61
+
62
+ const manifest = generateManifest(config);
63
+ expect(manifest.description).toBe('Save session state');
64
+ });
65
+
66
+ it('should use custom description when provided', () => {
67
+ const config: HookConversionConfig = {
68
+ name: 'test-hook',
69
+ event: 'PreCompact',
70
+ action: 'warn',
71
+ message: 'Save session state',
72
+ description: 'Custom description here',
73
+ };
74
+
75
+ const manifest = generateManifest(config);
76
+ expect(manifest.description).toBe('Custom description here');
77
+ });
78
+
79
+ it('should set empty matcher for non-tool events', () => {
80
+ const config: HookConversionConfig = {
81
+ name: 'compact-hook',
82
+ event: 'PreCompact',
83
+ action: 'remind',
84
+ message: 'Capture session',
85
+ };
86
+
87
+ const manifest = generateManifest(config);
88
+ expect(manifest.lifecycleHooks![0].matcher).toBe('');
89
+ });
90
+ });
91
+
92
+ describe('generateHookScript', () => {
93
+ it('should produce a valid shell script with shebang', () => {
94
+ const config: HookConversionConfig = {
95
+ name: 'test-hook',
96
+ event: 'PreToolUse',
97
+ toolMatcher: 'Write',
98
+ action: 'remind',
99
+ message: 'Check before writing',
100
+ };
101
+
102
+ const script = generateHookScript(config);
103
+
104
+ expect(script).toMatch(/^#!\/bin\/sh/);
105
+ expect(script).toContain('set -eu');
106
+ expect(script).toContain('INPUT=$(cat)');
107
+ });
108
+
109
+ it('should include tool matcher case statement for PreToolUse', () => {
110
+ const config: HookConversionConfig = {
111
+ name: 'write-guard',
112
+ event: 'PreToolUse',
113
+ toolMatcher: 'Write|Edit',
114
+ action: 'warn',
115
+ message: 'Be careful with writes',
116
+ };
117
+
118
+ const script = generateHookScript(config);
119
+
120
+ expect(script).toContain('TOOL_NAME=');
121
+ expect(script).toContain('case "$TOOL_NAME" in');
122
+ expect(script).toContain('Write|Edit');
123
+ });
124
+
125
+ it('should include file pattern matching when patterns provided', () => {
126
+ const config: HookConversionConfig = {
127
+ name: 'marketing-guard',
128
+ event: 'PreToolUse',
129
+ toolMatcher: 'Write',
130
+ filePatterns: ['**/marketing/**'],
131
+ action: 'block',
132
+ message: 'Marketing files require review',
133
+ };
134
+
135
+ const script = generateHookScript(config);
136
+
137
+ expect(script).toContain('FILE_PATH=');
138
+ expect(script).toContain('MATCHED=false');
139
+ expect(script).toContain('grep -qE');
140
+ });
141
+
142
+ it('should output block JSON for action=block', () => {
143
+ const config: HookConversionConfig = {
144
+ name: 'blocker',
145
+ event: 'PreToolUse',
146
+ action: 'block',
147
+ message: 'Blocked operation',
148
+ };
149
+
150
+ const script = generateHookScript(config);
151
+
152
+ expect(script).toContain('continue: false');
153
+ expect(script).toContain('BLOCKED:');
154
+ });
155
+
156
+ it('should output warn JSON for action=warn', () => {
157
+ const config: HookConversionConfig = {
158
+ name: 'warner',
159
+ event: 'PreToolUse',
160
+ action: 'warn',
161
+ message: 'Warning message',
162
+ };
163
+
164
+ const script = generateHookScript(config);
165
+
166
+ expect(script).toContain('continue: true');
167
+ expect(script).toContain('WARNING:');
168
+ });
169
+
170
+ it('should output remind JSON for action=remind', () => {
171
+ const config: HookConversionConfig = {
172
+ name: 'reminder',
173
+ event: 'PreToolUse',
174
+ action: 'remind',
175
+ message: 'Reminder message',
176
+ };
177
+
178
+ const script = generateHookScript(config);
179
+
180
+ expect(script).toContain('continue: true');
181
+ expect(script).toContain('REMINDER:');
182
+ });
183
+
184
+ it('should not include tool matching for PreCompact event', () => {
185
+ const config: HookConversionConfig = {
186
+ name: 'compact-hook',
187
+ event: 'PreCompact',
188
+ action: 'remind',
189
+ message: 'Save state before compaction',
190
+ };
191
+
192
+ const script = generateHookScript(config);
193
+
194
+ expect(script).not.toContain('TOOL_NAME');
195
+ expect(script).not.toContain('case');
196
+ });
197
+
198
+ it('should not include tool matching for Notification event', () => {
199
+ const config: HookConversionConfig = {
200
+ name: 'notify-hook',
201
+ event: 'Notification',
202
+ action: 'remind',
203
+ message: 'Notification handler',
204
+ };
205
+
206
+ const script = generateHookScript(config);
207
+
208
+ expect(script).not.toContain('TOOL_NAME');
209
+ });
210
+
211
+ it('should not include tool matching for Stop event', () => {
212
+ const config: HookConversionConfig = {
213
+ name: 'stop-hook',
214
+ event: 'Stop',
215
+ action: 'remind',
216
+ message: 'Stop handler',
217
+ };
218
+
219
+ const script = generateHookScript(config);
220
+
221
+ expect(script).not.toContain('TOOL_NAME');
222
+ });
223
+ });
224
+
225
+ describe('constants', () => {
226
+ it('HOOK_EVENTS should contain all 5 events', () => {
227
+ expect(HOOK_EVENTS).toEqual([
228
+ 'PreToolUse',
229
+ 'PostToolUse',
230
+ 'PreCompact',
231
+ 'Notification',
232
+ 'Stop',
233
+ ]);
234
+ });
235
+
236
+ it('ACTION_LEVELS should contain all 3 levels', () => {
237
+ expect(ACTION_LEVELS).toEqual(['remind', 'warn', 'block']);
238
+ });
239
+ });
240
+
241
+ describe('validation', () => {
242
+ it('should reject invalid event', () => {
243
+ const invalidEvent = 'InvalidEvent';
244
+ expect(HOOK_EVENTS.includes(invalidEvent as any)).toBe(false);
245
+ });
246
+
247
+ it('should reject invalid action level', () => {
248
+ const invalidAction = 'destroy';
249
+ expect(ACTION_LEVELS.includes(invalidAction as any)).toBe(false);
250
+ });
251
+
252
+ it('should accept all valid events', () => {
253
+ for (const event of HOOK_EVENTS) {
254
+ expect(HOOK_EVENTS.includes(event)).toBe(true);
255
+ }
256
+ });
257
+
258
+ it('should accept all valid action levels', () => {
259
+ for (const action of ACTION_LEVELS) {
260
+ expect(ACTION_LEVELS.includes(action)).toBe(true);
261
+ }
262
+ });
263
+ });
264
+
265
+ describe('directory structure', () => {
266
+ it('should create correct built-in directory structure', () => {
267
+ const config: HookConversionConfig = {
268
+ name: 'test-pack',
269
+ event: 'PreToolUse',
270
+ toolMatcher: 'Write',
271
+ action: 'remind',
272
+ message: 'Test message',
273
+ };
274
+
275
+ const script = generateHookScript(config);
276
+ const manifest = generateManifest(config);
277
+
278
+ // Simulate built-in output
279
+ const baseDir = join(tempDir, 'hook-packs', config.name);
280
+ const scriptsDir = join(baseDir, 'scripts');
281
+ mkdirSync(scriptsDir, { recursive: true });
282
+
283
+ const { writeFileSync: wfs, chmodSync: cms } = require('node:fs');
284
+ wfs(join(baseDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
285
+ wfs(join(scriptsDir, `${config.name}.sh`), script);
286
+ cms(join(scriptsDir, `${config.name}.sh`), 0o755);
287
+
288
+ // Verify structure
289
+ expect(existsSync(join(baseDir, 'manifest.json'))).toBe(true);
290
+ expect(existsSync(join(scriptsDir, `${config.name}.sh`))).toBe(true);
291
+
292
+ // Verify manifest is valid JSON
293
+ const manifestContent = readFileSync(join(baseDir, 'manifest.json'), 'utf-8');
294
+ const parsed = JSON.parse(manifestContent);
295
+ expect(parsed.name).toBe('test-pack');
296
+ expect(parsed.version).toBe('1.0.0');
297
+ expect(parsed.scripts).toHaveLength(1);
298
+ expect(parsed.lifecycleHooks).toHaveLength(1);
299
+
300
+ // Verify script content
301
+ const scriptContent = readFileSync(join(scriptsDir, `${config.name}.sh`), 'utf-8');
302
+ expect(scriptContent).toContain('#!/bin/sh');
303
+ expect(scriptContent).toContain('test-pack');
304
+
305
+ // Verify script is executable
306
+ const stat = statSync(join(scriptsDir, `${config.name}.sh`));
307
+ expect(stat.mode & 0o755).toBe(0o755);
308
+ });
309
+
310
+ it('should create correct project directory structure with --project flag', () => {
311
+ const config: HookConversionConfig = {
312
+ name: 'project-hook',
313
+ event: 'PostToolUse',
314
+ action: 'warn',
315
+ message: 'Project-local hook',
316
+ };
317
+
318
+ const script = generateHookScript(config);
319
+ const manifest = generateManifest(config);
320
+
321
+ // Simulate --project output
322
+ const baseDir = join(tempDir, '.soleri', 'hook-packs', config.name);
323
+ const scriptsDir = join(baseDir, 'scripts');
324
+ mkdirSync(scriptsDir, { recursive: true });
325
+
326
+ const { writeFileSync: wfs, chmodSync: cms } = require('node:fs');
327
+ wfs(join(baseDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
328
+ wfs(join(scriptsDir, `${config.name}.sh`), script);
329
+ cms(join(scriptsDir, `${config.name}.sh`), 0o755);
330
+
331
+ // Verify --project path structure
332
+ expect(
333
+ existsSync(join(tempDir, '.soleri', 'hook-packs', 'project-hook', 'manifest.json')),
334
+ ).toBe(true);
335
+ expect(
336
+ existsSync(
337
+ join(tempDir, '.soleri', 'hook-packs', 'project-hook', 'scripts', 'project-hook.sh'),
338
+ ),
339
+ ).toBe(true);
340
+ });
341
+ });
342
+ });
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { execSync } from 'node:child_process';
3
+ import { generateFixtures, validateHookScript } from '../hook-packs/validator.js';
4
+ import type { TestFixture } from '../hook-packs/validator.js';
5
+
6
+ // Mock execSync to avoid needing actual shell scripts in tests
7
+ vi.mock('node:child_process', () => ({
8
+ execSync: vi.fn(() => ''),
9
+ }));
10
+
11
+ const mockedExecSync = vi.mocked(execSync);
12
+
13
+ describe('validator', () => {
14
+ describe('generateFixtures', () => {
15
+ it('should return 15 fixtures for PreToolUse (5 matching + 10 non-matching)', () => {
16
+ const fixtures = generateFixtures('PreToolUse', 'Write');
17
+ expect(fixtures).toHaveLength(15);
18
+
19
+ const matching = fixtures.filter((f) => f.shouldMatch);
20
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
21
+ expect(matching).toHaveLength(5);
22
+ expect(nonMatching).toHaveLength(10);
23
+ });
24
+
25
+ it('should return 15 fixtures for PostToolUse', () => {
26
+ const fixtures = generateFixtures('PostToolUse', 'Edit|Write');
27
+ expect(fixtures).toHaveLength(15);
28
+
29
+ const matching = fixtures.filter((f) => f.shouldMatch);
30
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
31
+ expect(matching).toHaveLength(5);
32
+ expect(nonMatching).toHaveLength(10);
33
+ });
34
+
35
+ it('should return 15 fixtures for PreCompact', () => {
36
+ const fixtures = generateFixtures('PreCompact');
37
+ expect(fixtures).toHaveLength(15);
38
+
39
+ const matching = fixtures.filter((f) => f.shouldMatch);
40
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
41
+ expect(matching).toHaveLength(5);
42
+ expect(nonMatching).toHaveLength(10);
43
+ });
44
+
45
+ it('should return 15 fixtures for Notification', () => {
46
+ const fixtures = generateFixtures('Notification');
47
+ expect(fixtures).toHaveLength(15);
48
+ });
49
+
50
+ it('should return 15 fixtures for Stop', () => {
51
+ const fixtures = generateFixtures('Stop');
52
+ expect(fixtures).toHaveLength(15);
53
+ });
54
+
55
+ it('matching PreToolUse fixtures should have shouldMatch: true', () => {
56
+ const fixtures = generateFixtures('PreToolUse', 'Bash');
57
+ const matching = fixtures.filter((f) => f.shouldMatch);
58
+ for (const f of matching) {
59
+ expect(f.shouldMatch).toBe(true);
60
+ }
61
+ });
62
+
63
+ it('non-matching PreToolUse fixtures should have shouldMatch: false', () => {
64
+ const fixtures = generateFixtures('PreToolUse', 'Write');
65
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
66
+ for (const f of nonMatching) {
67
+ expect(f.shouldMatch).toBe(false);
68
+ }
69
+ });
70
+
71
+ it('PreToolUse matching fixtures should contain tool_name and tool_input', () => {
72
+ const fixtures = generateFixtures('PreToolUse', 'Write|Edit');
73
+ const matching = fixtures.filter((f) => f.shouldMatch);
74
+ for (const f of matching) {
75
+ expect(f.payload).toHaveProperty('tool_name');
76
+ expect(f.payload).toHaveProperty('tool_input');
77
+ const toolInput = f.payload.tool_input as Record<string, unknown>;
78
+ expect(toolInput).toHaveProperty('file_path');
79
+ expect(toolInput).toHaveProperty('command');
80
+ }
81
+ });
82
+
83
+ it('PreToolUse non-matching fixtures should contain tool_name and tool_input', () => {
84
+ const fixtures = generateFixtures('PreToolUse', 'Write');
85
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
86
+ for (const f of nonMatching) {
87
+ expect(f.payload).toHaveProperty('tool_name');
88
+ expect(f.payload).toHaveProperty('tool_input');
89
+ }
90
+ });
91
+
92
+ it('should use provided toolMatcher tools in matching fixtures', () => {
93
+ const fixtures = generateFixtures('PreToolUse', 'Edit|Write');
94
+ const matching = fixtures.filter((f) => f.shouldMatch);
95
+ const toolNames = matching.map((f) => f.payload.tool_name);
96
+ for (const name of toolNames) {
97
+ expect(['Edit', 'Write']).toContain(name);
98
+ }
99
+ });
100
+
101
+ it('should default to Write when no toolMatcher provided for PreToolUse', () => {
102
+ const fixtures = generateFixtures('PreToolUse');
103
+ const matching = fixtures.filter((f) => f.shouldMatch);
104
+ for (const f of matching) {
105
+ expect(f.payload.tool_name).toBe('Write');
106
+ }
107
+ });
108
+
109
+ it('PreCompact matching fixtures should have session_id', () => {
110
+ const fixtures = generateFixtures('PreCompact');
111
+ const matching = fixtures.filter((f) => f.shouldMatch);
112
+ for (const f of matching) {
113
+ expect(f.payload).toHaveProperty('session_id');
114
+ expect(f.payload).toHaveProperty('context');
115
+ }
116
+ });
117
+
118
+ it('PreCompact non-matching fixtures should have empty payloads', () => {
119
+ const fixtures = generateFixtures('PreCompact');
120
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
121
+ for (const f of nonMatching) {
122
+ expect(Object.keys(f.payload)).toHaveLength(0);
123
+ }
124
+ });
125
+
126
+ it('all fixtures should have event matching the requested event', () => {
127
+ for (const event of [
128
+ 'PreToolUse',
129
+ 'PostToolUse',
130
+ 'PreCompact',
131
+ 'Notification',
132
+ 'Stop',
133
+ ] as const) {
134
+ const fixtures = generateFixtures(event);
135
+ for (const f of fixtures) {
136
+ expect(f.event).toBe(event);
137
+ }
138
+ }
139
+ });
140
+
141
+ it('all fixtures should have unique names', () => {
142
+ const fixtures = generateFixtures('PreToolUse', 'Write|Edit');
143
+ const names = fixtures.map((f) => f.name);
144
+ expect(new Set(names).size).toBe(names.length);
145
+ });
146
+ });
147
+
148
+ describe('validateHookScript', () => {
149
+ it('should report correctly with a script that produces no output (exit 0)', () => {
150
+ // execSync mock returns '' (empty string) — no match detected
151
+ mockedExecSync.mockReturnValue('');
152
+
153
+ const fixtures: TestFixture[] = [
154
+ {
155
+ name: 'should-match',
156
+ event: 'PreToolUse',
157
+ payload: { tool_name: 'Write', tool_input: { file_path: 'test.ts' } },
158
+ shouldMatch: true,
159
+ },
160
+ {
161
+ name: 'should-not-match',
162
+ event: 'PreToolUse',
163
+ payload: { tool_name: 'Read', tool_input: { file_path: 'test.ts' } },
164
+ shouldMatch: false,
165
+ },
166
+ ];
167
+
168
+ const report = validateHookScript('/fake/script.sh', fixtures);
169
+
170
+ expect(report.total).toBe(2);
171
+ // Script produces no output, so matched = false for all
172
+ // should-match expected match but got none -> false negative
173
+ // should-not-match expected no match and got none -> correct
174
+ expect(report.falseNegatives).toHaveLength(1);
175
+ expect(report.falseNegatives[0].fixture.name).toBe('should-match');
176
+ expect(report.falsePositives).toHaveLength(0);
177
+ expect(report.passed).toBe(1);
178
+ });
179
+
180
+ it('should detect false positives when script always matches', () => {
181
+ mockedExecSync.mockReturnValue('{"continue": true, "message": "always matches"}');
182
+
183
+ const fixtures: TestFixture[] = [
184
+ {
185
+ name: 'should-match',
186
+ event: 'PreToolUse',
187
+ payload: { tool_name: 'Write', tool_input: {} },
188
+ shouldMatch: true,
189
+ },
190
+ {
191
+ name: 'should-not-match',
192
+ event: 'PreToolUse',
193
+ payload: { tool_name: 'Read', tool_input: {} },
194
+ shouldMatch: false,
195
+ },
196
+ ];
197
+
198
+ const report = validateHookScript('/fake/script.sh', fixtures);
199
+
200
+ expect(report.total).toBe(2);
201
+ // Script always outputs "continue", so matched = true for all
202
+ // should-not-match expected no match but got one -> false positive
203
+ expect(report.falsePositives).toHaveLength(1);
204
+ expect(report.falsePositives[0].fixture.name).toBe('should-not-match');
205
+ expect(report.falseNegatives).toHaveLength(0);
206
+ expect(report.passed).toBe(1);
207
+ });
208
+
209
+ it('should report all passed when script matches correctly', () => {
210
+ mockedExecSync.mockImplementation((cmd: unknown) => {
211
+ if (typeof cmd === 'string' && cmd.includes('Write')) {
212
+ return '{"continue": true, "message": "matched"}';
213
+ }
214
+ return '';
215
+ });
216
+
217
+ const fixtures: TestFixture[] = [
218
+ {
219
+ name: 'should-match',
220
+ event: 'PreToolUse',
221
+ payload: { tool_name: 'Write', tool_input: {} },
222
+ shouldMatch: true,
223
+ },
224
+ {
225
+ name: 'should-not-match',
226
+ event: 'PreToolUse',
227
+ payload: { tool_name: 'Read', tool_input: {} },
228
+ shouldMatch: false,
229
+ },
230
+ ];
231
+
232
+ const report = validateHookScript('/fake/script.sh', fixtures);
233
+
234
+ expect(report.total).toBe(2);
235
+ expect(report.passed).toBe(2);
236
+ expect(report.falsePositives).toHaveLength(0);
237
+ expect(report.falseNegatives).toHaveLength(0);
238
+ });
239
+
240
+ it('should handle script errors gracefully (exit code != 0)', () => {
241
+ mockedExecSync.mockImplementation(() => {
242
+ const err = new Error('script failed') as Error & { status: number; stdout: string };
243
+ err.status = 1;
244
+ err.stdout = '';
245
+ throw err;
246
+ });
247
+
248
+ const fixtures: TestFixture[] = [
249
+ {
250
+ name: 'error-fixture',
251
+ event: 'PreToolUse',
252
+ payload: { tool_name: 'Write', tool_input: {} },
253
+ shouldMatch: true,
254
+ },
255
+ ];
256
+
257
+ const report = validateHookScript('/fake/script.sh', fixtures);
258
+
259
+ expect(report.total).toBe(1);
260
+ // Error means matched = false, but shouldMatch = true -> false negative
261
+ expect(report.falseNegatives).toHaveLength(1);
262
+ expect(report.passed).toBe(0);
263
+ });
264
+ });
265
+ });