@dalzoubi/dev-agents-sync 2.0.24 → 2.0.26

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.
@@ -0,0 +1,684 @@
1
+ /**
2
+ * tests/hook-retirement.test.mjs
3
+ *
4
+ * Slice S1 (retire-rules-injector-hook) — CLI de-registration migration tests.
5
+ * Spec: docs/specs/260724-opus5-native-overlap/ ("Hook de-registration
6
+ * (prerequisite for S1)"). Plan: docs/specs/260724-opus5-native-overlap/plan.md
7
+ * ("Hook de-registration (S1, CLI)").
8
+ *
9
+ * These tests are written RED-first. `RETIRED_HOOK_SCRIPTS` does not exist in
10
+ * src/settings.mjs yet, and `computeMerge`/`mergeHookSettings` is purely
11
+ * additive today — it has no prune branch. They FAIL now and go GREEN once
12
+ * the implement agent adds the append-only `RETIRED_HOOK_SCRIPTS` list and
13
+ * extends the merge into a merge-then-prune pass, per plan.md.
14
+ *
15
+ * Behavior-anchored (§7): each criterion below maps to a named test that
16
+ * would fail if the behavior broke.
17
+ *
18
+ * P1. A settings.json carrying the marker-managed (HOOK_TAG-tagged)
19
+ * SessionStart -> rules-injector.sh entry has that entry pruned by
20
+ * mergeHookSettings.
21
+ * P2. A consumer-authored (untagged) entry for the SAME retired command
22
+ * survives the prune untouched, byte-for-byte, even when a tagged
23
+ * managed entry for that same command sits alongside it on the same
24
+ * event. This is the reversibility crux — the prune must never touch
25
+ * unmanaged or consumer-owned state.
26
+ * P3. A consumer with no such entry anywhere sees literally no change:
27
+ * the file stays byte-identical and the merge reports no change.
28
+ * P4. Pruning is idempotent — running the merge twice against the same
29
+ * fixture leaves the settings identical after the first and second
30
+ * calls, and the second call reports nothing left to prune.
31
+ * P5. --dry-run reports the prune (via the merge result) but writes
32
+ * nothing — the file on disk stays byte-identical.
33
+ * P6. The other three managed hooks (steering-injector.sh,
34
+ * managed-file-guard.sh, uncommitted-check.sh) — on three DIFFERENT
35
+ * events — are untouched by the prune: same tag, same matcher, same
36
+ * command, before and after.
37
+ *
38
+ * ASSUMED src/settings.mjs INTERFACE (for the implement agent to match; this
39
+ * is the intended public surface per plan.md's "Hook de-registration (S1, CLI)"
40
+ * section, not yet implemented):
41
+ *
42
+ * export const RETIRED_HOOK_SCRIPTS: { event: string, matcher: string, command: string }[]
43
+ * Append-only. Its first entry targets `.claude/hooks/rules-injector.sh`.
44
+ *
45
+ * mergeHookSettings(consumerCwd, hookScripts, opts?)
46
+ * Existing additive-merge contract is UNCHANGED and extended, not
47
+ * replaced: it still appends any hookScripts entry that is absent. NEW:
48
+ * after the additive pass, it also PRUNES any settings entry that (a)
49
+ * carries HOOK_TAG === true AND (b) has a hooks[].command matching a
50
+ * command in RETIRED_HOOK_SCRIPTS — regardless of which event bucket it
51
+ * lives under, and regardless of whether that command also appears in
52
+ * the `hookScripts` argument. Untagged (consumer-authored) entries are
53
+ * NEVER pruned, even when their command matches a retired one.
54
+ * Returns { merged, dryRun, settingsPath, prunedRetired }:
55
+ * prunedRetired: string[] — the retired commands actually removed by
56
+ * this call. Always an array (empty when nothing was pruned), present
57
+ * on every call including no-op ones, so a caller/test can tell "no
58
+ * retired entries existed" apart from "retirement isn't supported."
59
+ * `merged` is true if EITHER the additive merge OR the prune changed
60
+ * the settings object.
61
+ *
62
+ * `RETIRED_HOOK_SCRIPTS` is imported dynamically (not via a static named
63
+ * import) so a still-missing export surfaces as a readable assertion failure
64
+ * ("RETIRED_HOOK_SCRIPTS must be a non-empty array...") rather than a
65
+ * module-load SyntaxError that would abort every test in this file with an
66
+ * opaque failure unrelated to the specific behavior under test.
67
+ *
68
+ * Test runner: `node --test packages/dev-agents-sync/tests/hook-retirement.test.mjs`
69
+ * No third-party dependencies. All tests use isolated tmp dirs.
70
+ */
71
+
72
+ import { describe, it, beforeEach, afterEach } from 'node:test';
73
+ import assert from 'node:assert/strict';
74
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
75
+ import { tmpdir } from 'node:os';
76
+ import path from 'node:path';
77
+ import { fileURLToPath } from 'node:url';
78
+
79
+ import { mergeHookSettings, HOOK_TAG } from '../src/settings.mjs';
80
+
81
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Lazy import of RETIRED_HOOK_SCRIPTS — see the file header for why this is
85
+ // dynamic rather than a static named import.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ let _settingsMod = null;
89
+ async function importSettings() {
90
+ if (_settingsMod) return _settingsMod;
91
+ _settingsMod = await import('../src/settings.mjs');
92
+ return _settingsMod;
93
+ }
94
+
95
+ const RETIRED_COMMAND = '.claude/hooks/rules-injector.sh';
96
+
97
+ /**
98
+ * The intended POST-S1 caller-supplied hookScripts list — i.e. what a fixed
99
+ * DEFAULT_HOOK_SCRIPTS should look like once rules-injector.sh is removed
100
+ * from it. Hardcoded here (rather than imported from DEFAULT_HOOK_SCRIPTS)
101
+ * so these tests exercise the prune mechanism in isolation and do not
102
+ * silently start passing/failing as a side effect of a later, unrelated
103
+ * edit to DEFAULT_HOOK_SCRIPTS itself.
104
+ */
105
+ const FUTURE_HOOK_SCRIPTS = [
106
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
107
+ { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
108
+ { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
109
+ ];
110
+
111
+ function makeTmpDir() {
112
+ return mkdtempSync(path.join(tmpdir(), 'das-hook-retirement-test-'));
113
+ }
114
+
115
+ function settingsPathFor(consumerDir) {
116
+ return path.join(consumerDir, '.claude', 'settings.json');
117
+ }
118
+
119
+ function writeSettings(consumerDir, obj) {
120
+ mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
121
+ writeFileSync(settingsPathFor(consumerDir), JSON.stringify(obj, null, 2), 'utf8');
122
+ }
123
+
124
+ function readSettingsRaw(consumerDir) {
125
+ return readFileSync(settingsPathFor(consumerDir), 'utf8');
126
+ }
127
+
128
+ function readSettingsJson(consumerDir) {
129
+ return JSON.parse(readSettingsRaw(consumerDir));
130
+ }
131
+
132
+ /** A CLI-managed (tagged) entry, mirroring settings.mjs's buildManagedEntry shape. */
133
+ function taggedEntry(matcher, command) {
134
+ return { [HOOK_TAG]: true, matcher, hooks: [{ type: 'command', command }] };
135
+ }
136
+
137
+ /** A consumer-authored (untagged) entry for the same shape. */
138
+ function consumerEntry(matcher, command) {
139
+ return { matcher, hooks: [{ type: 'command', command }] };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // T0 — RETIRED_HOOK_SCRIPTS export exists and targets rules-injector.sh
144
+ //
145
+ // A dedicated, isolated assertion so a still-missing export fails with a
146
+ // direct, readable message rather than surfacing only as a downstream
147
+ // assertion mismatch in P1-P6.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('S1 T0 — RETIRED_HOOK_SCRIPTS export', () => {
151
+ it('T0a: RETIRED_HOOK_SCRIPTS is exported as a non-empty array', async () => {
152
+ const { RETIRED_HOOK_SCRIPTS } = await importSettings();
153
+ assert.ok(
154
+ Array.isArray(RETIRED_HOOK_SCRIPTS) && RETIRED_HOOK_SCRIPTS.length > 0,
155
+ `settings.mjs must export a non-empty RETIRED_HOOK_SCRIPTS array; got: ${JSON.stringify(RETIRED_HOOK_SCRIPTS)}`,
156
+ );
157
+ });
158
+
159
+ it('T0b: RETIRED_HOOK_SCRIPTS has an entry whose command targets .claude/hooks/rules-injector.sh', async () => {
160
+ const { RETIRED_HOOK_SCRIPTS } = await importSettings();
161
+ const list = Array.isArray(RETIRED_HOOK_SCRIPTS) ? RETIRED_HOOK_SCRIPTS : [];
162
+ const entry = list.find((h) => h && h.command === RETIRED_COMMAND);
163
+ assert.ok(
164
+ entry,
165
+ `RETIRED_HOOK_SCRIPTS must contain an entry with command "${RETIRED_COMMAND}" ` +
166
+ `(rules-injector.sh is the first retired hook per plan.md); ` +
167
+ `got commands: ${list.map((h) => h?.command).join(', ') || '(none)'}`,
168
+ );
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // P1 — Managed retired entry is pruned
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe('S1 P1 — mergeHookSettings prunes a managed (tagged) retired entry', () => {
177
+ let consumerDir;
178
+
179
+ beforeEach(() => {
180
+ consumerDir = makeTmpDir();
181
+ });
182
+
183
+ afterEach(() => {
184
+ rmSync(consumerDir, { recursive: true, force: true });
185
+ });
186
+
187
+ it('P1a: the tagged SessionStart entry for rules-injector.sh is removed', async () => {
188
+ writeSettings(consumerDir, {
189
+ hooks: { SessionStart: [taggedEntry('.*', RETIRED_COMMAND)] },
190
+ });
191
+
192
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
193
+
194
+ const settings = readSettingsJson(consumerDir);
195
+ const sessionStart = settings.hooks?.SessionStart ?? [];
196
+ const stillPresent = sessionStart.some(
197
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
198
+ );
199
+ assert.ok(
200
+ !stillPresent,
201
+ `the managed rules-injector.sh SessionStart entry must be pruned; ` +
202
+ `got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
203
+ );
204
+ });
205
+
206
+ it('P1b: result.prunedRetired reports the removed command', async () => {
207
+ writeSettings(consumerDir, {
208
+ hooks: { SessionStart: [taggedEntry('.*', RETIRED_COMMAND)] },
209
+ });
210
+
211
+ const result = await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
212
+
213
+ assert.ok(
214
+ Array.isArray(result.prunedRetired),
215
+ `mergeHookSettings result must carry a prunedRetired array; got: ${JSON.stringify(result)}`,
216
+ );
217
+ assert.ok(
218
+ result.prunedRetired.includes(RETIRED_COMMAND),
219
+ `result.prunedRetired must include "${RETIRED_COMMAND}"; got: ${JSON.stringify(result.prunedRetired)}`,
220
+ );
221
+ });
222
+
223
+ it('P1c: result.merged is true when a retired entry was pruned', async () => {
224
+ writeSettings(consumerDir, {
225
+ hooks: { SessionStart: [taggedEntry('.*', RETIRED_COMMAND)] },
226
+ });
227
+
228
+ const result = await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
229
+
230
+ assert.equal(
231
+ result.merged,
232
+ true,
233
+ 'mergeHookSettings must report merged: true when a retired entry was pruned',
234
+ );
235
+ });
236
+ });
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // P2 — Unmanaged / consumer-authored entry is NOT pruned (the most important
240
+ // test in the set: it protects consumer-owned settings.json state).
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe('S1 P2 — an unmanaged entry for the retired command survives untouched', () => {
244
+ let consumerDir;
245
+
246
+ beforeEach(() => {
247
+ consumerDir = makeTmpDir();
248
+ });
249
+
250
+ afterEach(() => {
251
+ rmSync(consumerDir, { recursive: true, force: true });
252
+ });
253
+
254
+ it('P2: a consumer-authored entry pointing at the retired command is preserved byte-for-byte, even alongside a managed one on the same event', async () => {
255
+ // Deliberately place BOTH a tagged (managed) entry and an untagged
256
+ // (consumer-authored) entry for the SAME command on the SAME event —
257
+ // the exact scenario a naive "delete anything matching this command"
258
+ // prune would get wrong.
259
+ const consumerOwned = consumerEntry('.*', RETIRED_COMMAND);
260
+ writeSettings(consumerDir, {
261
+ hooks: {
262
+ SessionStart: [taggedEntry('.*', RETIRED_COMMAND), consumerOwned],
263
+ },
264
+ });
265
+
266
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
267
+
268
+ const settings = readSettingsJson(consumerDir);
269
+ const sessionStart = settings.hooks?.SessionStart ?? [];
270
+ const matching = sessionStart.filter(
271
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
272
+ );
273
+
274
+ // Exactly one survivor: the consumer's, and it must be untagged and
275
+ // byte-identical in shape to what was written.
276
+ assert.equal(
277
+ matching.length,
278
+ 1,
279
+ `exactly one entry for ${RETIRED_COMMAND} must survive (the consumer's); ` +
280
+ `got ${matching.length}: ${JSON.stringify(matching)}`,
281
+ );
282
+ assert.equal(
283
+ matching[0][HOOK_TAG],
284
+ undefined,
285
+ 'the surviving entry must be the untagged consumer-authored one, not the managed one',
286
+ );
287
+ assert.deepEqual(
288
+ matching[0],
289
+ consumerOwned,
290
+ 'the consumer-authored entry must be byte-identical (untouched) after the prune',
291
+ );
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // P3 — Absent entry is a no-op
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe('S1 P3 — no retired entry present means no change', () => {
300
+ let consumerDir;
301
+
302
+ beforeEach(() => {
303
+ consumerDir = makeTmpDir();
304
+ });
305
+
306
+ afterEach(() => {
307
+ rmSync(consumerDir, { recursive: true, force: true });
308
+ });
309
+
310
+ it('P3: a consumer already fully wired with the three survivors and no retired entry sees a byte-identical file and merged: false', async () => {
311
+ // Already fully wired to the FUTURE (post-retirement) hook set, with no
312
+ // rules-injector.sh entry anywhere — the additive pass has nothing to
313
+ // add, and the prune pass has nothing to remove.
314
+ writeSettings(consumerDir, {
315
+ hooks: {
316
+ SessionStart: [taggedEntry('.*', '.claude/hooks/steering-injector.sh')],
317
+ PostToolUse: [taggedEntry('.*', '.claude/hooks/managed-file-guard.sh')],
318
+ Stop: [taggedEntry('.*', '.claude/hooks/uncommitted-check.sh')],
319
+ },
320
+ });
321
+ const before = readSettingsRaw(consumerDir);
322
+
323
+ const result = await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
324
+
325
+ const after = readSettingsRaw(consumerDir);
326
+ assert.equal(after, before, 'settings.json must be byte-identical when there is nothing to merge or prune');
327
+ assert.equal(result.merged, false, 'result.merged must be false on a true no-op');
328
+ assert.ok(
329
+ Array.isArray(result.prunedRetired) && result.prunedRetired.length === 0,
330
+ `result.prunedRetired must be an empty array when nothing was pruned; got: ${JSON.stringify(result.prunedRetired)}`,
331
+ );
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // P4 — Prune is idempotent
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('S1 P4 — repeated merges are idempotent', () => {
340
+ let consumerDir;
341
+
342
+ beforeEach(() => {
343
+ consumerDir = makeTmpDir();
344
+ });
345
+
346
+ afterEach(() => {
347
+ rmSync(consumerDir, { recursive: true, force: true });
348
+ });
349
+
350
+ it('P4: a second merge after the prune reports no further change and leaves the file identical', async () => {
351
+ writeSettings(consumerDir, {
352
+ hooks: { SessionStart: [taggedEntry('.*', RETIRED_COMMAND)] },
353
+ });
354
+
355
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
356
+ const afterFirst = readSettingsRaw(consumerDir);
357
+
358
+ const secondResult = await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
359
+ const afterSecond = readSettingsRaw(consumerDir);
360
+
361
+ assert.equal(
362
+ afterSecond,
363
+ afterFirst,
364
+ 'settings.json must be byte-identical after a second merge (idempotent, §9)',
365
+ );
366
+ assert.equal(secondResult.merged, false, 'the second merge must report merged: false');
367
+ assert.ok(
368
+ Array.isArray(secondResult.prunedRetired) && secondResult.prunedRetired.length === 0,
369
+ `the second merge must report an empty prunedRetired (nothing left to prune); got: ${JSON.stringify(secondResult.prunedRetired)}`,
370
+ );
371
+ });
372
+ });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // P5 — --dry-run reports without writing
376
+ // ---------------------------------------------------------------------------
377
+
378
+ describe('S1 P5 — --dry-run reports the prune but writes nothing', () => {
379
+ let consumerDir;
380
+
381
+ beforeEach(() => {
382
+ consumerDir = makeTmpDir();
383
+ });
384
+
385
+ afterEach(() => {
386
+ rmSync(consumerDir, { recursive: true, force: true });
387
+ });
388
+
389
+ it('P5: dry-run reports merged: true and the pruned command, but the file on disk stays byte-identical', async () => {
390
+ writeSettings(consumerDir, {
391
+ hooks: { SessionStart: [taggedEntry('.*', RETIRED_COMMAND)] },
392
+ });
393
+ const before = readSettingsRaw(consumerDir);
394
+
395
+ const result = await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS, { dryRun: true });
396
+
397
+ assert.equal(result.dryRun, true, 'result.dryRun must be true');
398
+ assert.equal(result.merged, true, 'dry-run must still report that a change (the prune) would occur');
399
+ assert.ok(
400
+ Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
401
+ `dry-run result.prunedRetired must report "${RETIRED_COMMAND}" would be pruned; got: ${JSON.stringify(result.prunedRetired)}`,
402
+ );
403
+
404
+ const after = readSettingsRaw(consumerDir);
405
+ assert.equal(after, before, '--dry-run must not write anything — the file must stay byte-identical');
406
+ });
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // S1 cycle 2 RED — deliver-validate findings H1 (major) and M4 (minor).
411
+ //
412
+ // H1: pruneRetiredEntries (settings.mjs:163-185, the `continue` at :172) drops
413
+ // the ENTIRE managed entry when ANY command in entry.hooks[] matches a
414
+ // retired command. The CLI's own buildManagedEntry always emits one command
415
+ // per entry, so the CLI itself never produces a multi-command entry — but a
416
+ // multi-command hooks[] array is idiomatic Claude Code settings shape, and a
417
+ // consumer hand-adding their own command into the CLI's already-tagged entry
418
+ // produces exactly this shape. That consumer command is then deleted
419
+ // permanently (it is unmanaged, so nothing re-adds it), violating the
420
+ // invariant stated at settings.mjs:10-12 ("remove our own registrations for
421
+ // RETIRED_HOOK_SCRIPTS, never anyone else's"): the prune is entry-granular
422
+ // today; the invariant it claims to uphold is command-granular.
423
+ //
424
+ // Intended fix (not yet implemented): filter entry.hooks down to its
425
+ // non-retired commands, keep the entry with the filtered array, and remove
426
+ // the entry only when hooks empties.
427
+ //
428
+ // M4: the implementation comment at settings.mjs:158-161 claims the prune
429
+ // scans every event bucket (not just SessionStart) specifically so a
430
+ // pre-retirement CLI's registration under a different event is still caught.
431
+ // Nothing pinned that claim, and nothing pinned that odd entry shapes (no
432
+ // hooks[] key, non-string command) don't crash the prune.
433
+ // ---------------------------------------------------------------------------
434
+
435
+ describe('S1 H1 — prune is hook-granular, not entry-granular (a consumer command sharing a tagged entry must survive)', () => {
436
+ let consumerDir;
437
+
438
+ beforeEach(() => {
439
+ consumerDir = makeTmpDir();
440
+ });
441
+
442
+ afterEach(() => {
443
+ rmSync(consumerDir, { recursive: true, force: true });
444
+ });
445
+
446
+ const CONSUMER_COMMAND = '.claude/hooks/consumer-owned-check.sh';
447
+
448
+ it('H1a: a tagged entry whose hooks[] holds both the retired command and a consumer-added command survives the prune, with only the retired command removed', async () => {
449
+ const mixedEntry = {
450
+ [HOOK_TAG]: true,
451
+ matcher: '.*',
452
+ hooks: [
453
+ { type: 'command', command: RETIRED_COMMAND },
454
+ { type: 'command', command: CONSUMER_COMMAND },
455
+ ],
456
+ };
457
+ writeSettings(consumerDir, { hooks: { SessionStart: [mixedEntry] } });
458
+
459
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
460
+
461
+ const settings = readSettingsJson(consumerDir);
462
+ const sessionStart = settings.hooks?.SessionStart ?? [];
463
+ const survivor = sessionStart.find((e) => e[HOOK_TAG] === true);
464
+ assert.ok(
465
+ survivor,
466
+ `the tagged entry must survive the prune (only its retired command should be removed, ` +
467
+ `not the whole entry — the prune must be hook-granular); got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
468
+ );
469
+ const commands = (survivor?.hooks ?? []).map((h) => h.command);
470
+ assert.ok(
471
+ !commands.includes(RETIRED_COMMAND),
472
+ `the retired command must be gone from the surviving entry; got commands: ${JSON.stringify(commands)}`,
473
+ );
474
+ assert.ok(
475
+ commands.includes(CONSUMER_COMMAND),
476
+ `the consumer-added command sharing the entry must be intact after the prune (it is not ` +
477
+ `ours to delete); got commands: ${JSON.stringify(commands)}`,
478
+ );
479
+ });
480
+
481
+ it('H1b: a tagged entry whose hooks[] holds ONLY the retired command is removed entirely, without suppressing an unrelated append into the same bucket (must not regress)', async () => {
482
+ // NOTE: FUTURE_HOOK_SCRIPTS registers steering-injector.sh under
483
+ // SessionStart too — the SAME bucket as pureRetiredEntry. A correct merge
484
+ // both drops the fully-retired entry AND appends the legitimate
485
+ // steering-injector.sh entry, so the bucket is NOT empty afterward — it
486
+ // holds exactly the surviving append. Asserting `sessionStart.length ===
487
+ // 0` here would be unsatisfiable by ANY correct implementation (current
488
+ // or hook-granular-fixed alike), since it conflates "the retired entry is
489
+ // gone" with "nothing else may land in this bucket." Assert the actual
490
+ // guard instead: no entry carrying the retired command survives, and the
491
+ // concurrent append is untouched (proving the prune did not over-reach).
492
+ const pureRetiredEntry = taggedEntry('.*', RETIRED_COMMAND);
493
+ writeSettings(consumerDir, { hooks: { SessionStart: [pureRetiredEntry] } });
494
+
495
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
496
+
497
+ const settings = readSettingsJson(consumerDir);
498
+ const sessionStart = settings.hooks?.SessionStart ?? [];
499
+ const retiredStillPresent = sessionStart.some(
500
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
501
+ );
502
+ assert.ok(
503
+ !retiredStillPresent,
504
+ `no entry carrying the retired command must survive once its hooks[] would empty entirely; ` +
505
+ `got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
506
+ );
507
+
508
+ const steeringRegistered = sessionStart.some(
509
+ (e) =>
510
+ Array.isArray(e.hooks) &&
511
+ e.hooks.some((h) => h.command === '.claude/hooks/steering-injector.sh'),
512
+ );
513
+ assert.ok(
514
+ steeringRegistered,
515
+ `the concurrent steering-injector.sh append into the same SessionStart bucket must still ` +
516
+ `succeed — the prune must not suppress an unrelated append; got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
517
+ );
518
+ });
519
+
520
+ it('H1c: pruning a mixed entry does not un-register a surviving managed hook that shared the same entry', async () => {
521
+ // Second-order effect: appendMissingEntries runs FIRST and sees the
522
+ // shipped command (steering-injector.sh) already present inside the
523
+ // mixed entry, so it never re-adds it as a separate entry. If the prune
524
+ // then drops the WHOLE entry (today's entry-granular behavior), the
525
+ // surviving managed hook vanishes with nothing left to re-register it —
526
+ // an un-registration deliver-validate flagged as a real regression risk,
527
+ // not just a data-loss risk.
528
+ const SURVIVING_COMMAND = '.claude/hooks/steering-injector.sh';
529
+ const mixedEntry = {
530
+ [HOOK_TAG]: true,
531
+ matcher: '.*',
532
+ hooks: [
533
+ { type: 'command', command: RETIRED_COMMAND },
534
+ { type: 'command', command: SURVIVING_COMMAND },
535
+ ],
536
+ };
537
+ writeSettings(consumerDir, { hooks: { SessionStart: [mixedEntry] } });
538
+
539
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
540
+
541
+ const settings = readSettingsJson(consumerDir);
542
+ const sessionStart = settings.hooks?.SessionStart ?? [];
543
+ const stillRegistered = sessionStart.some(
544
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === SURVIVING_COMMAND),
545
+ );
546
+ assert.ok(
547
+ stillRegistered,
548
+ `steering-injector.sh must still be registered under SessionStart after a prune touches a ` +
549
+ `mixed entry it shared with the retired command; got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
550
+ );
551
+ });
552
+ });
553
+
554
+ describe('S1 M4 — cross-bucket prune claim and odd entry shapes', () => {
555
+ let consumerDir;
556
+
557
+ beforeEach(() => {
558
+ consumerDir = makeTmpDir();
559
+ });
560
+
561
+ afterEach(() => {
562
+ rmSync(consumerDir, { recursive: true, force: true });
563
+ });
564
+
565
+ it('M4a: a retired command registered under a non-SessionStart bucket (PostToolUse) is still pruned', async () => {
566
+ writeSettings(consumerDir, { hooks: { PostToolUse: [taggedEntry('.*', RETIRED_COMMAND)] } });
567
+
568
+ const result = await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
569
+
570
+ const settings = readSettingsJson(consumerDir);
571
+ const postToolUse = settings.hooks?.PostToolUse ?? [];
572
+ const stillPresent = postToolUse.some(
573
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
574
+ );
575
+ assert.ok(
576
+ !stillPresent,
577
+ `a retired command registered under PostToolUse (not SessionStart) must still be pruned by ` +
578
+ `the all-bucket scan settings.mjs:158-161 claims exists; got hooks.PostToolUse: ${JSON.stringify(postToolUse)}`,
579
+ );
580
+ assert.ok(
581
+ result.prunedRetired.includes(RETIRED_COMMAND),
582
+ `result.prunedRetired must report the command pruned from the non-SessionStart bucket; got: ${JSON.stringify(result.prunedRetired)}`,
583
+ );
584
+ });
585
+
586
+ it('M4b: a tagged entry with no hooks[] key does not crash the prune and is left untouched', async () => {
587
+ const noHooksEntry = { [HOOK_TAG]: true, matcher: '.*' };
588
+ writeSettings(consumerDir, { hooks: { SessionStart: [noHooksEntry] } });
589
+
590
+ assert.doesNotThrow(
591
+ () => mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS),
592
+ 'mergeHookSettings must not throw on a tagged entry missing the hooks[] key',
593
+ );
594
+
595
+ const settings = readSettingsJson(consumerDir);
596
+ const sessionStart = settings.hooks?.SessionStart ?? [];
597
+ const survivor = sessionStart.find((e) => e[HOOK_TAG] === true && !('hooks' in e));
598
+ assert.deepEqual(
599
+ survivor,
600
+ noHooksEntry,
601
+ `the no-hooks[]-key tagged entry must survive the prune untouched; got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
602
+ );
603
+ });
604
+
605
+ it('M4c: a tagged entry with a non-string command does not crash the prune and is left untouched', async () => {
606
+ const weirdEntry = {
607
+ [HOOK_TAG]: true,
608
+ matcher: '.*',
609
+ hooks: [{ type: 'command', command: 12345 }],
610
+ };
611
+ writeSettings(consumerDir, { hooks: { SessionStart: [weirdEntry] } });
612
+
613
+ assert.doesNotThrow(
614
+ () => mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS),
615
+ 'mergeHookSettings must not throw on a tagged entry whose command is not a string',
616
+ );
617
+
618
+ const settings = readSettingsJson(consumerDir);
619
+ const sessionStart = settings.hooks?.SessionStart ?? [];
620
+ const survivor = sessionStart.find(
621
+ (e) => e[HOOK_TAG] === true && Array.isArray(e.hooks) && e.hooks.some((h) => h.command === 12345),
622
+ );
623
+ assert.deepEqual(
624
+ survivor,
625
+ weirdEntry,
626
+ `the non-string-command tagged entry must survive the prune untouched; got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
627
+ );
628
+ });
629
+ });
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // P6 — Other managed hooks are untouched
633
+ // ---------------------------------------------------------------------------
634
+
635
+ describe('S1 P6 — the other three managed hooks survive the prune unchanged', () => {
636
+ let consumerDir;
637
+
638
+ beforeEach(() => {
639
+ consumerDir = makeTmpDir();
640
+ });
641
+
642
+ afterEach(() => {
643
+ rmSync(consumerDir, { recursive: true, force: true });
644
+ });
645
+
646
+ it('P6: steering-injector.sh, managed-file-guard.sh, and uncommitted-check.sh entries are byte-identical after the prune', async () => {
647
+ const steering = taggedEntry('.*', '.claude/hooks/steering-injector.sh');
648
+ const guard = taggedEntry('.*', '.claude/hooks/managed-file-guard.sh');
649
+ const uncommitted = taggedEntry('.*', '.claude/hooks/uncommitted-check.sh');
650
+ const retired = taggedEntry('.*', RETIRED_COMMAND);
651
+
652
+ writeSettings(consumerDir, {
653
+ hooks: {
654
+ SessionStart: [steering, retired],
655
+ PostToolUse: [guard],
656
+ Stop: [uncommitted],
657
+ },
658
+ });
659
+
660
+ await mergeHookSettings(consumerDir, FUTURE_HOOK_SCRIPTS);
661
+
662
+ const settings = readSettingsJson(consumerDir);
663
+
664
+ // The retired entry is gone; the survivor on the same event is untouched.
665
+ const sessionStart = settings.hooks?.SessionStart ?? [];
666
+ assert.equal(
667
+ sessionStart.length,
668
+ 1,
669
+ `SessionStart must retain only the steering-injector.sh entry after prune; got: ${JSON.stringify(sessionStart)}`,
670
+ );
671
+ assert.deepEqual(sessionStart[0], steering, 'steering-injector.sh entry must be byte-identical after the prune');
672
+
673
+ assert.deepEqual(
674
+ settings.hooks?.PostToolUse,
675
+ [guard],
676
+ 'managed-file-guard.sh (PostToolUse) must be untouched by the prune',
677
+ );
678
+ assert.deepEqual(
679
+ settings.hooks?.Stop,
680
+ [uncommitted],
681
+ 'uncommitted-check.sh (Stop) must be untouched by the prune',
682
+ );
683
+ });
684
+ });