@abarcenas/ai-workflow-template 1.49.0 → 2.0.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 (35) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +2 -2
  2. package/.agents/instructions/learned-knowledge.instructions.md +47 -2
  3. package/.agents/instructions/memory-schema.instructions.md +1 -1
  4. package/.github/workflows/npm-publish.yml +69 -1
  5. package/.opencode/plugins/graphify.js +22 -0
  6. package/LICENSE +15 -0
  7. package/README.md +56 -22
  8. package/memory-bank/.vocabulary.json +3 -0
  9. package/opencode.mcp.example.json +13 -0
  10. package/package.json +20 -10
  11. package/scripts/bump-version.js +18 -2
  12. package/scripts/mcp/playwright-mcp-launcher.js +45 -2
  13. package/scripts/bump-version.test.js +0 -238
  14. package/scripts/knowledgebase-cli.test.js +0 -576
  15. package/scripts/knowledgebase-index.test.js +0 -759
  16. package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
  17. package/scripts/mcp-knowledgebase-server.test.js +0 -745
  18. package/scripts/mcp-memory-server.test.js +0 -398
  19. package/scripts/memory-cli.test.js +0 -297
  20. package/scripts/memory-index.test.js +0 -1173
  21. package/scripts/normalize-memory.test.js +0 -753
  22. package/scripts/setup/discover.test.js +0 -530
  23. package/scripts/setup/hooks.test.js +0 -722
  24. package/scripts/setup/husky-init.test.js +0 -311
  25. package/scripts/setup/index.test.js +0 -1021
  26. package/scripts/setup/knowledgebase.test.js +0 -352
  27. package/scripts/setup/prepare.test.js +0 -718
  28. package/scripts/setup/skills-phase.test.js +0 -1274
  29. package/scripts/setup/sync-phase.test.js +0 -373
  30. package/scripts/setup/ui.test.js +0 -579
  31. package/scripts/setup/utils.test.js +0 -586
  32. package/scripts/sync-unit.test.js +0 -1881
  33. package/scripts/sync.test.js +0 -472
  34. package/scripts/validate-memory-schema.test.js +0 -328
  35. package/scripts/vocab-sync.test.js +0 -902
@@ -1,722 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // hooks.test.js — Unit tests for the hook installation module
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // Tests the 6-case merge algorithm implemented in hooks.js using real temp
6
- // directories. No mocks on fs — we create and inspect real files.
7
- //
8
- // Covered scenarios (≥10, per plan T15):
9
- // 1. Case A — No .husky/ dir → created
10
- // 2. Case B — .husky/ exists, no hook file → created
11
- // 3. Case C — Managed hook present → overwritten
12
- // 4. Case D — Unmanaged hook + --force → overwritten (+ .bak)
13
- // 5. Case E — Unmanaged hook, no --force → merged
14
- // 6. Case F — dryRun=true → dry-run, no writes
15
- // 7. Partial failure → mixed results
16
- // 8. .husky is a file (not a dir) → skipped
17
- // 9. Executable permission (chmod) → 0o755
18
- // 10. Empty .husky/ dir → works (Case B)
19
- // 11. flags.force alternative → overwritten
20
- // 12. Partial existingHooks map → graceful
21
- // 13. Result structure validation → all fields present
22
- // ═════════════════════════════════════════════════════════════════════════════
23
-
24
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
25
- import {
26
- mkdtempSync,
27
- mkdirSync,
28
- writeFileSync,
29
- readFileSync,
30
- statSync,
31
- existsSync,
32
- rmSync,
33
- realpathSync,
34
- } from 'fs'
35
- import { join } from 'path'
36
- import { tmpdir } from 'os'
37
-
38
- import { installHooks } from './hooks.js'
39
- import { HOOK_MARKER, HOOK_MERGE_SEPARATOR, TEMPLATE_HOOKS } from './constants.js'
40
-
41
- // ── Test helpers ──────────────────────────────────────────────────────────
42
-
43
- const tempDirs = new Set()
44
-
45
- /**
46
- * Creates and registers a temporary directory for a single test case.
47
- * The directory is automatically cleaned up in `afterEach`.
48
- *
49
- * @returns {string} Absolute path to the new temp directory.
50
- */
51
- function createTempDir() {
52
- const dir = mkdtempSync(join(realpathSync(tmpdir()), 'hooks-test-'))
53
- tempDirs.add(dir)
54
- return dir
55
- }
56
-
57
- function cleanupTempDirs() {
58
- for (const dir of tempDirs) {
59
- try {
60
- rmSync(dir, { recursive: true, force: true })
61
- } catch {
62
- /* best-effort cleanup */
63
- }
64
- }
65
- tempDirs.clear()
66
- }
67
-
68
- /**
69
- * Returns the expected hook file content as produced by `buildHookContent()`.
70
- *
71
- * The template content in `TEMPLATE_HOOKS` already includes `HOOK_MARKER`
72
- * as its first line, so this just returns the raw content directly.
73
- *
74
- * @param {string} hookName - Key from `TEMPLATE_HOOKS` ('pre-commit' | 'post-merge' | 'post-commit')
75
- * @returns {string} The full hook file content as written by hooks.js.
76
- */
77
- function expectedContent(hookName) {
78
- return TEMPLATE_HOOKS[hookName].content
79
- }
80
-
81
- /**
82
- * Builds a context object suitable for `installHooks()`.
83
- *
84
- * Always uses a fresh temp directory as `consumerRoot` unless an explicit
85
- * `consumerRoot` is passed in `overrides`.
86
- *
87
- * @param {object} [overrides] - Partial context properties to override.
88
- * @returns {object} Context object.
89
- */
90
- function makeContext(overrides = {}) {
91
- const dir = overrides.consumerRoot || createTempDir()
92
- return {
93
- consumerRoot: dir,
94
- hasHuskyDir: false,
95
- dryRun: false,
96
- force: false,
97
- existingHooks: { /* empty by default */ },
98
- flags: {},
99
- ...overrides,
100
- consumerRoot: dir, // enforce temp dir regardless of overrides
101
- }
102
- }
103
-
104
- // ── Tests ─────────────────────────────────────────────────────────────────
105
-
106
- describe('installHooks', () => {
107
- afterEach(() => {
108
- cleanupTempDirs()
109
- })
110
-
111
- // ── 1. Case A: No .husky/ directory ───────────────────────────────────
112
-
113
- it('Case A: no .husky/ dir → creates dir + hook files, returns action="created"', async () => {
114
- const ctx = makeContext({ hasHuskyDir: false })
115
-
116
- const results = await installHooks(ctx)
117
-
118
- // .husky/ directory was created
119
- const huskyDir = join(ctx.consumerRoot, '.husky')
120
- expect(existsSync(huskyDir)).toBe(true)
121
- expect(statSync(huskyDir).isDirectory()).toBe(true)
122
-
123
- // Every template hook file is present with correct content and permissions
124
- for (const hookName of Object.keys(TEMPLATE_HOOKS)) {
125
- const fp = join(huskyDir, hookName)
126
- expect(existsSync(fp)).toBe(true)
127
- expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(hookName))
128
- expect(statSync(fp).mode & 0o111).not.toBe(0)
129
- }
130
-
131
- // Results match the expected count and action
132
- expect(results).toHaveLength(Object.keys(TEMPLATE_HOOKS).length)
133
- for (const r of results) {
134
- expect(r.action).toBe('created')
135
- }
136
- })
137
-
138
- // ── 2. Case B: .husky/ exists, no existing hook file ──────────────────
139
-
140
- it('Case B: .husky/ exists, no existing hook → creates hook file, returns action="created"', async () => {
141
- const ctx = makeContext({
142
- hasHuskyDir: true,
143
- existingHooks: {
144
- 'pre-commit': { exists: false, isManaged: false },
145
- 'post-merge': { exists: false, isManaged: false },
146
- 'post-commit': { exists: false, isManaged: false },
147
- },
148
- })
149
- mkdirSync(join(ctx.consumerRoot, '.husky'))
150
-
151
- const results = await installHooks(ctx)
152
-
153
- const huskyDir = join(ctx.consumerRoot, '.husky')
154
- for (const hookName of Object.keys(TEMPLATE_HOOKS)) {
155
- const fp = join(huskyDir, hookName)
156
- expect(existsSync(fp)).toBe(true)
157
- expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(hookName))
158
- expect(statSync(fp).mode & 0o111).not.toBe(0)
159
- }
160
-
161
- for (const r of results) {
162
- expect(r.action).toBe('created')
163
- }
164
- })
165
-
166
- // ── 3. Case C: Managed hook present → idempotent overwrite ────────────
167
-
168
- it('Case C: .husky/ exists, managed hook present → overwrites, returns action="overwritten"', async () => {
169
- const ctx = makeContext({
170
- hasHuskyDir: true,
171
- existingHooks: {
172
- 'pre-commit': { exists: true, isManaged: true },
173
- 'post-merge': { exists: true, isManaged: true },
174
- 'post-commit': { exists: true, isManaged: true },
175
- },
176
- })
177
- const huskyDir = join(ctx.consumerRoot, '.husky')
178
- mkdirSync(huskyDir)
179
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
180
- writeFileSync(join(huskyDir, name), '# old content\n')
181
- }
182
-
183
- const results = await installHooks(ctx)
184
-
185
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
186
- const fp = join(huskyDir, name)
187
- // Content was updated
188
- expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(name))
189
- // Executable
190
- expect(statSync(fp).mode & 0o111).not.toBe(0)
191
- // No .bak created (idempotent overwrite)
192
- expect(existsSync(fp + '.bak')).toBe(false)
193
- }
194
-
195
- for (const r of results) {
196
- expect(r.action).toBe('overwritten')
197
- }
198
- })
199
-
200
- // ── 4. Case D: Unmanaged + --force → backup + overwrite ───────────────
201
-
202
- it('Case D: .husky/ exists, unmanaged hook, --force → backups to .bak, overwrites', async () => {
203
- const ctx = makeContext({
204
- hasHuskyDir: true,
205
- force: true,
206
- existingHooks: {
207
- 'pre-commit': { exists: true, isManaged: false },
208
- 'post-merge': { exists: true, isManaged: false },
209
- 'post-commit': { exists: true, isManaged: false },
210
- },
211
- })
212
- const huskyDir = join(ctx.consumerRoot, '.husky')
213
- mkdirSync(huskyDir)
214
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
215
- writeFileSync(join(huskyDir, name), `# ${name} original\n`)
216
- }
217
-
218
- const results = await installHooks(ctx)
219
-
220
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
221
- const fp = join(huskyDir, name)
222
-
223
- // .bak exists with original content
224
- expect(existsSync(fp + '.bak')).toBe(true)
225
- expect(readFileSync(fp + '.bak', 'utf-8')).toBe(`# ${name} original\n`)
226
-
227
- // Hook file has new content + executable
228
- expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(name))
229
- expect(statSync(fp).mode & 0o111).not.toBe(0)
230
- }
231
-
232
- for (const r of results) {
233
- expect(r.action).toBe('overwritten')
234
- expect(r.message).toContain('.bak')
235
- }
236
- })
237
-
238
- // ── 5. Case E: Unmanaged, no --force → append ─────────────────────────
239
-
240
- it('Case E: .husky/ exists, unmanaged hook, no --force → appends separator+content', async () => {
241
- const ctx = makeContext({
242
- hasHuskyDir: true,
243
- force: false,
244
- existingHooks: {
245
- 'pre-commit': { exists: true, isManaged: false },
246
- 'post-merge': { exists: true, isManaged: false },
247
- 'post-commit': { exists: true, isManaged: false },
248
- },
249
- })
250
- const huskyDir = join(ctx.consumerRoot, '.husky')
251
- mkdirSync(huskyDir)
252
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
253
- writeFileSync(join(huskyDir, name), `# ${name} original\n`)
254
- }
255
-
256
- const results = await installHooks(ctx)
257
-
258
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
259
- const fp = join(huskyDir, name)
260
- const content = readFileSync(fp, 'utf-8')
261
-
262
- // Original content preserved
263
- expect(content).toContain(`# ${name} original`)
264
- // Separator inserted between original and new content
265
- expect(content).toContain(HOOK_MERGE_SEPARATOR)
266
- // New content appended
267
- expect(content).toContain(expectedContent(name))
268
- // File is executable
269
- expect(statSync(fp).mode & 0o111).not.toBe(0)
270
- // No .bak in merge mode
271
- expect(existsSync(fp + '.bak')).toBe(false)
272
- }
273
-
274
- for (const r of results) {
275
- expect(r.action).toBe('merged')
276
- }
277
- })
278
-
279
- // ── 6. Case F: dry-run ────────────────────────────────────────────────
280
-
281
- it('Case F: dryRun=true → returns action="dry-run", no files written', async () => {
282
- const ctx = makeContext({
283
- hasHuskyDir: false,
284
- dryRun: true,
285
- })
286
-
287
- const results = await installHooks(ctx)
288
-
289
- // No files or directories were created
290
- expect(existsSync(join(ctx.consumerRoot, '.husky'))).toBe(false)
291
-
292
- for (const r of results) {
293
- expect(r.action).toBe('dry-run')
294
- }
295
- })
296
-
297
- // ── 7. Partial failure: one hook succeeds, the other fails ────────────
298
-
299
- it('partial failure: one hook succeeds, other fails → mixed results, errors do not block', async () => {
300
- const ctx = makeContext({
301
- hasHuskyDir: true,
302
- existingHooks: {
303
- 'pre-commit': { exists: true, isManaged: true },
304
- 'post-merge': { exists: true, isManaged: true },
305
- 'post-commit': { exists: true, isManaged: true },
306
- },
307
- })
308
- const huskyDir = join(ctx.consumerRoot, '.husky')
309
- mkdirSync(huskyDir)
310
-
311
- // Make post-merge a directory → writeFileSync fails (EISDIR)
312
- mkdirSync(join(huskyDir, 'post-merge'), { recursive: true })
313
- writeFileSync(join(huskyDir, 'pre-commit'), '# old\n')
314
-
315
- const results = await installHooks(ctx)
316
-
317
- expect(results).toHaveLength(Object.keys(TEMPLATE_HOOKS).length)
318
-
319
- const preCommitResult = results.find((r) => r.hook === 'pre-commit')
320
- const postMergeResult = results.find((r) => r.hook === 'post-merge')
321
-
322
- expect(preCommitResult.action).toBe('overwritten')
323
- expect(postMergeResult.action).toBe('skipped')
324
-
325
- // Successful hook's file was updated
326
- expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(
327
- expectedContent('pre-commit'),
328
- )
329
- })
330
-
331
- // ── 8. .husky is a file, not a directory ──────────────────────────────
332
-
333
- it('.husky is a file, not a directory → graceful error, returns action="skipped"', async () => {
334
- const ctx = makeContext({
335
- hasHuskyDir: false,
336
- existingHooks: {},
337
- })
338
-
339
- // Create .husky as a regular file — ensureDir will fail
340
- writeFileSync(join(ctx.consumerRoot, '.husky'), '# i am a file\n', 'utf-8')
341
-
342
- const results = await installHooks(ctx)
343
-
344
- for (const r of results) {
345
- expect(r.action).toBe('skipped')
346
- }
347
-
348
- // The .husky "file" is still a file
349
- expect(statSync(join(ctx.consumerRoot, '.husky')).isFile()).toBe(true)
350
- })
351
-
352
- // ── 9. Executable permission check ────────────────────────────────────
353
-
354
- it('created hook files are made executable (chmod 755)', async () => {
355
- const ctx = makeContext({ hasHuskyDir: false })
356
- await installHooks(ctx)
357
-
358
- const huskyDir = join(ctx.consumerRoot, '.husky')
359
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
360
- const fp = join(huskyDir, name)
361
- const mode = statSync(fp).mode
362
-
363
- // Owner execute (0o100)
364
- expect(mode & 0o100).not.toBe(0)
365
- // Group execute (0o010)
366
- expect(mode & 0o010).not.toBe(0)
367
- // Other execute (0o001)
368
- expect(mode & 0o001).not.toBe(0)
369
- }
370
- })
371
-
372
- // ── 10. Empty .husky/ dir → works correctly (Case B) ──────────────────
373
-
374
- it('empty .husky/ dir (no files at all) → works correctly', async () => {
375
- const ctx = makeContext({
376
- hasHuskyDir: true,
377
- existingHooks: {
378
- 'pre-commit': { exists: false, isManaged: false },
379
- 'post-merge': { exists: false, isManaged: false },
380
- 'post-commit': { exists: false, isManaged: false },
381
- },
382
- })
383
- mkdirSync(join(ctx.consumerRoot, '.husky'))
384
-
385
- const results = await installHooks(ctx)
386
-
387
- const huskyDir = join(ctx.consumerRoot, '.husky')
388
- for (const name of Object.keys(TEMPLATE_HOOKS)) {
389
- const fp = join(huskyDir, name)
390
- expect(existsSync(fp)).toBe(true)
391
- expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(name))
392
- expect(statSync(fp).mode & 0o111).not.toBe(0)
393
- }
394
-
395
- for (const r of results) {
396
- expect(r.action).toBe('created')
397
- }
398
- })
399
-
400
- // ── 11. flags.force alternative path ───────────────────────────────────
401
-
402
- it('flags.force is respected as alternative to direct context.force', async () => {
403
- const ctx = makeContext({
404
- hasHuskyDir: true,
405
- force: false, // context-level force is false
406
- flags: { force: true }, // but flags object has force=true
407
- existingHooks: {
408
- 'pre-commit': { exists: true, isManaged: false },
409
- 'post-merge': { exists: true, isManaged: false },
410
- 'post-commit': { exists: true, isManaged: false },
411
- },
412
- })
413
- const huskyDir = join(ctx.consumerRoot, '.husky')
414
- mkdirSync(huskyDir)
415
- writeFileSync(join(huskyDir, 'pre-commit'), '# orig\n')
416
- writeFileSync(join(huskyDir, 'post-merge'), '# orig\n')
417
- writeFileSync(join(huskyDir, 'post-commit'), '# orig\n')
418
-
419
- const results = await installHooks(ctx)
420
-
421
- for (const r of results) {
422
- expect(r.action).toBe('overwritten')
423
- }
424
- expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(true)
425
- expect(existsSync(join(huskyDir, 'post-merge.bak'))).toBe(true)
426
- expect(existsSync(join(huskyDir, 'post-commit.bak'))).toBe(true)
427
- })
428
-
429
- // ── 12. Partial existingHooks map ─────────────────────────────────────
430
-
431
- it('handles partial existingHooks (one hook missing from map)', async () => {
432
- const ctx = makeContext({
433
- hasHuskyDir: true,
434
- existingHooks: {
435
- 'pre-commit': { exists: true, isManaged: true },
436
- // post-merge absent → treated as exists=false, isManaged=false
437
- // post-commit absent → treated as exists=false, isManaged=false
438
- },
439
- })
440
- const huskyDir = join(ctx.consumerRoot, '.husky')
441
- mkdirSync(huskyDir)
442
- writeFileSync(join(huskyDir, 'pre-commit'), '# old\n')
443
-
444
- const results = await installHooks(ctx)
445
-
446
- const preCommitResult = results.find((r) => r.hook === 'pre-commit')
447
- const postMergeResult = results.find((r) => r.hook === 'post-merge')
448
-
449
- expect(preCommitResult.action).toBe('overwritten')
450
- expect(postMergeResult.action).toBe('created')
451
-
452
- // Post-merge was created from scratch
453
- expect(existsSync(join(huskyDir, 'post-merge'))).toBe(true)
454
- })
455
-
456
- // ── 13. Result structure validation ───────────────────────────────────
457
-
458
- it('each result entry has hook, action, and message fields with correct types', async () => {
459
- const ctx = makeContext({ hasHuskyDir: false })
460
- const results = await installHooks(ctx)
461
-
462
- for (const r of results) {
463
- expect(r).toHaveProperty('hook')
464
- expect(r).toHaveProperty('action')
465
- expect(r).toHaveProperty('message')
466
- expect(typeof r.hook).toBe('string')
467
- expect(typeof r.action).toBe('string')
468
- expect(typeof r.message).toBe('string')
469
- }
470
-
471
- // All expected hook names are present
472
- const hookNames = results.map((r) => r.hook).sort()
473
- expect(hookNames).toEqual(Object.keys(TEMPLATE_HOOKS).sort())
474
- })
475
-
476
- // ── 14. Dry-run with existing unmanaged hooks + force ─────────────────
477
-
478
- it('dry-run with existing unmanaged hooks + force flag → no .bak files created', async () => {
479
- const ctx = makeContext({
480
- hasHuskyDir: true,
481
- dryRun: true,
482
- force: true,
483
- existingHooks: {
484
- 'pre-commit': { exists: true, isManaged: false },
485
- 'post-merge': { exists: true, isManaged: false },
486
- 'post-commit': { exists: true, isManaged: false },
487
- },
488
- })
489
- const huskyDir = join(ctx.consumerRoot, '.husky')
490
- mkdirSync(huskyDir)
491
- writeFileSync(join(huskyDir, 'pre-commit'), '# original\n')
492
- writeFileSync(join(huskyDir, 'post-merge'), '# original\n')
493
-
494
- const results = await installHooks(ctx)
495
-
496
- // Files are completely unchanged
497
- expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe('# original\n')
498
- expect(readFileSync(join(huskyDir, 'post-merge'), 'utf-8')).toBe('# original\n')
499
- // No .bak files created
500
- expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
501
- expect(existsSync(join(huskyDir, 'post-merge.bak'))).toBe(false)
502
-
503
- for (const r of results) {
504
- expect(r.action).toBe('dry-run')
505
- }
506
- })
507
-
508
- // ── 15. Idempotent overwrite does NOT create .bak ─────────────────────
509
-
510
- it('no .bak file created for idempotent overwrite (Case C, isManaged=true)', async () => {
511
- const ctx = makeContext({
512
- hasHuskyDir: true,
513
- existingHooks: {
514
- 'pre-commit': { exists: true, isManaged: true },
515
- 'post-merge': { exists: true, isManaged: true },
516
- 'post-commit': { exists: true, isManaged: true },
517
- },
518
- })
519
- const huskyDir = join(ctx.consumerRoot, '.husky')
520
- mkdirSync(huskyDir)
521
- writeFileSync(join(huskyDir, 'pre-commit'), '# managed content\n')
522
- writeFileSync(join(huskyDir, 'post-merge'), '# managed content\n')
523
-
524
- await installHooks(ctx)
525
-
526
- expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
527
- expect(existsSync(join(huskyDir, 'post-merge.bak'))).toBe(false)
528
- })
529
-
530
- // ── 16. Result messages contain hook name and description ─────────────
531
-
532
- it('result messages contain the hook name for traceability', async () => {
533
- const ctx = makeContext({ hasHuskyDir: false })
534
- const results = await installHooks(ctx)
535
-
536
- for (const r of results) {
537
- expect(r.message).toContain(r.hook)
538
- }
539
- })
540
- it('force-overwrites a hook that the context reports but disk lost', async () => {
541
- // Defensive path: context says the hook exists, but safeReadFile returns
542
- // null, so the backup is written with empty content.
543
- const ctx = makeContext({
544
- hasHuskyDir: true,
545
- force: true,
546
- existingHooks: {
547
- 'pre-commit': { exists: true, isManaged: false },
548
- 'post-merge': { exists: true, isManaged: false },
549
- 'post-commit': { exists: true, isManaged: false },
550
- },
551
- })
552
- mkdirSync(join(ctx.consumerRoot, '.husky'))
553
-
554
- const results = await installHooks(ctx)
555
-
556
- for (const r of results) {
557
- expect(r.action).toBe('overwritten')
558
- }
559
- const huskyDir = join(ctx.consumerRoot, '.husky')
560
- expect(readFileSync(join(huskyDir, 'pre-commit.bak'), 'utf-8')).toBe('')
561
- expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(
562
- expectedContent('pre-commit'),
563
- )
564
- })
565
-
566
- it('merges against empty content when the reported hook is gone from disk', async () => {
567
- const ctx = makeContext({
568
- hasHuskyDir: true,
569
- existingHooks: {
570
- 'pre-commit': { exists: true, isManaged: false },
571
- 'post-merge': { exists: true, isManaged: false },
572
- 'post-commit': { exists: true, isManaged: false },
573
- },
574
- })
575
- mkdirSync(join(ctx.consumerRoot, '.husky'))
576
-
577
- const results = await installHooks(ctx)
578
-
579
- for (const r of results) {
580
- expect(r.action).toBe('merged')
581
- }
582
- const merged = readFileSync(
583
- join(ctx.consumerRoot, '.husky', 'pre-commit'),
584
- 'utf-8',
585
- )
586
- expect(merged.startsWith(`\n${HOOK_MERGE_SEPARATOR}\n`)).toBe(true)
587
- expect(merged).toContain(expectedContent('pre-commit'))
588
- })
589
- })
590
-
591
- // ── 17. Dry-run action classification (all four message branches) ──────────
592
-
593
- describe('installHooks — dry-run classification', () => {
594
- let logSpy
595
-
596
- beforeEach(() => {
597
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
598
- })
599
-
600
- afterEach(() => {
601
- logSpy.mockRestore()
602
- cleanupTempDirs()
603
- })
604
-
605
- it('dry-run describes creating .husky/ when the directory is missing', async () => {
606
- const ctx = makeContext({ hasHuskyDir: false, dryRun: true })
607
-
608
- const results = await installHooks(ctx)
609
-
610
- expect(results).toHaveLength(Object.keys(TEMPLATE_HOOKS).length)
611
- for (const r of results) {
612
- expect(r.action).toBe('dry-run')
613
- expect(r.message).toContain('Would create .husky/')
614
- expect(r.message).toContain(r.hook)
615
- expect(r.message).toContain(
616
- TEMPLATE_HOOKS[r.hook].description,
617
- )
618
- }
619
- expect(existsSync(join(ctx.consumerRoot, '.husky'))).toBe(false)
620
- })
621
-
622
- it('dry-run describes writing when .husky/ exists without the hook', async () => {
623
- const ctx = makeContext({
624
- hasHuskyDir: true,
625
- dryRun: true,
626
- existingHooks: {
627
- 'pre-commit': { exists: false, isManaged: false },
628
- 'post-merge': { exists: false, isManaged: false },
629
- 'post-commit': { exists: false, isManaged: false },
630
- },
631
- })
632
- mkdirSync(join(ctx.consumerRoot, '.husky'))
633
-
634
- const results = await installHooks(ctx)
635
-
636
- for (const r of results) {
637
- expect(r.message).toContain('Would write')
638
- expect(r.message).not.toContain('Would append')
639
- }
640
- })
641
-
642
- it('dry-run describes an idempotent overwrite for managed hooks', async () => {
643
- const ctx = makeContext({
644
- hasHuskyDir: true,
645
- dryRun: true,
646
- existingHooks: {
647
- 'pre-commit': { exists: true, isManaged: true },
648
- 'post-merge': { exists: true, isManaged: true },
649
- 'post-commit': { exists: true, isManaged: true },
650
- },
651
- })
652
- const huskyDir = join(ctx.consumerRoot, '.husky')
653
- mkdirSync(huskyDir)
654
- writeFileSync(join(huskyDir, 'pre-commit'), `${HOOK_MARKER}\nold\n`)
655
- writeFileSync(join(huskyDir, 'post-merge'), `${HOOK_MARKER}\nold\n`)
656
- const before = readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')
657
-
658
- const results = await installHooks(ctx)
659
-
660
- for (const r of results) {
661
- expect(r.action).toBe('dry-run')
662
- expect(r.message).toContain('idempotent update')
663
- expect(r.message).toContain(r.hook)
664
- }
665
- expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(before)
666
- })
667
-
668
- it('dry-run describes appending for unmanaged hooks without force', async () => {
669
- const ctx = makeContext({
670
- hasHuskyDir: true,
671
- dryRun: true,
672
- force: false,
673
- flags: {},
674
- existingHooks: {
675
- 'pre-commit': { exists: true, isManaged: false },
676
- 'post-merge': { exists: true, isManaged: false },
677
- 'post-commit': { exists: true, isManaged: false },
678
- },
679
- })
680
- const huskyDir = join(ctx.consumerRoot, '.husky')
681
- mkdirSync(huskyDir)
682
- writeFileSync(join(huskyDir, 'pre-commit'), '#!/bin/sh\ncustom\n')
683
- writeFileSync(join(huskyDir, 'post-merge'), '#!/bin/sh\ncustom\n')
684
-
685
- const results = await installHooks(ctx)
686
-
687
- for (const r of results) {
688
- expect(r.action).toBe('dry-run')
689
- expect(r.message).toContain('Would append to existing')
690
- expect(r.message).not.toContain('force')
691
- }
692
- expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(
693
- '#!/bin/sh\ncustom\n',
694
- )
695
- expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
696
- })
697
-
698
- it('dry-run backs up + overwrites when force comes from flags', async () => {
699
- const ctx = makeContext({
700
- hasHuskyDir: true,
701
- dryRun: true,
702
- force: false,
703
- flags: { force: true },
704
- existingHooks: {
705
- 'pre-commit': { exists: true, isManaged: false },
706
- 'post-merge': { exists: true, isManaged: false },
707
- 'post-commit': { exists: true, isManaged: false },
708
- },
709
- })
710
- const huskyDir = join(ctx.consumerRoot, '.husky')
711
- mkdirSync(huskyDir)
712
-
713
- const results = await installHooks(ctx)
714
-
715
- for (const r of results) {
716
- expect(r.message).toContain('overwrite with force')
717
- }
718
- // Nothing is actually written or backed up
719
- expect(existsSync(join(huskyDir, 'pre-commit'))).toBe(false)
720
- expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
721
- })
722
- })