@dalzoubi/dev-agents-sync 2.0.25 → 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.
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/commands/init.mjs +19 -7
- package/src/commands/update.mjs +22 -11
- package/src/hooks.mjs +38 -1
- package/src/settings.mjs +203 -46
- package/tests/hook-retirement.test.mjs +684 -0
- package/tests/hooks-cli-install.test.mjs +67 -0
- package/tests/rules-injector-registration.test.mjs +523 -84
- package/tests/rules-injector-runtime.test.mjs +0 -313
|
@@ -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
|
+
});
|