@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
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tests/rules-injector-registration.test.mjs
|
|
3
3
|
*
|
|
4
|
-
* Slice S2
|
|
5
|
-
*
|
|
4
|
+
* Originally Slice S2 (docs/specs/260708-claude-rules-enforcement/) — proved
|
|
5
|
+
* that `rules-injector.sh` got wired into a consumer's `.claude/settings.json`
|
|
6
|
+
* as a managed SessionStart hook.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Slice S1 of docs/specs/260724-opus5-native-overlap/ ("retire-rules-injector-hook")
|
|
9
|
+
* retires that hook: Claude Code now loads `.claude/rules/*.md` natively as
|
|
10
|
+
* project instructions, so the hook is redundant, and the CLI gains a
|
|
11
|
+
* `RETIRED_HOOK_SCRIPTS` prune (see tests/hook-retirement.test.mjs for the
|
|
12
|
+
* settings.mjs-level merge-then-prune contract). T004 of that spec's tasks.md
|
|
13
|
+
* requires updating this file "to the retired-hook expectation" so it proves
|
|
14
|
+
* the OPPOSITE of what it proved before: rules-injector.sh is no longer
|
|
15
|
+
* registered by DEFAULT_HOOK_SCRIPTS, `init` never wires it for a new
|
|
16
|
+
* consumer, and `update` PRUNES it from an existing consumer who has it from
|
|
17
|
+
* before S1 shipped.
|
|
18
|
+
*
|
|
19
|
+
* These tests are written RED-first, alongside tests/hook-retirement.test.mjs
|
|
20
|
+
* and the S1 additions to tests/hooks-build-emission.test.mjs: they FAIL today
|
|
21
|
+
* because `.agents/hooks/rules-injector.sh` still exists, DEFAULT_HOOK_SCRIPTS
|
|
22
|
+
* still lists it, and mergeHookSettings has no prune branch yet. They go GREEN
|
|
23
|
+
* once S1 lands: the canonical hook source is deleted, DEFAULT_HOOK_SCRIPTS
|
|
24
|
+
* drops the entry, and RETIRED_HOOK_SCRIPTS + the merge-then-prune pass ship.
|
|
10
25
|
*
|
|
11
26
|
* Behavior anchors:
|
|
12
27
|
*
|
|
13
|
-
* R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs)
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* R2.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
28
|
+
* R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs) no
|
|
29
|
+
* longer has an entry targeting `.claude/hooks/rules-injector.sh`; it
|
|
30
|
+
* registers exactly the three surviving hooks.
|
|
31
|
+
* R2. `runInit` on a fresh consumer never wires a SessionStart registration
|
|
32
|
+
* for rules-injector.sh into `.claude/settings.json` — the three
|
|
33
|
+
* surviving managed hooks are still wired additively.
|
|
34
|
+
* R3. `runUpdate` on a consumer who already has a managed (HOOK_TAG-tagged)
|
|
35
|
+
* rules-injector.sh SessionStart entry from before S1 shipped PRUNES
|
|
36
|
+
* that entry, without disturbing the three surviving hooks.
|
|
37
|
+
* R4. Running update twice against that same pre-S1 consumer leaves the
|
|
38
|
+
* registration pruned — it is never reintroduced (idempotent removal,
|
|
39
|
+
* §9).
|
|
40
|
+
*
|
|
41
|
+
* This file stays a CLI-integration-level check (exercising the real
|
|
42
|
+
* runInit/runUpdate entry points end to end); it deliberately does not
|
|
43
|
+
* duplicate tests/hook-retirement.test.mjs's settings.mjs-level unit coverage
|
|
44
|
+
* of the merge-then-prune mechanism itself (P1-P6, T0 there).
|
|
25
45
|
*
|
|
26
46
|
* Test runner: `node --test packages/dev-agents-sync/tests/rules-injector-registration.test.mjs`
|
|
27
47
|
* No third-party dependencies. All tests use isolated tmp dirs.
|
|
@@ -53,6 +73,7 @@ import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
|
|
|
53
73
|
|
|
54
74
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
55
75
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
76
|
+
const RETIRED_COMMAND = '.claude/hooks/rules-injector.sh';
|
|
56
77
|
|
|
57
78
|
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
58
79
|
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
@@ -68,9 +89,11 @@ function collectFiles(baseDir, currentDir, map, prefix) {
|
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
/**
|
|
71
|
-
* Builds a fixture FileMap that includes the
|
|
72
|
-
*
|
|
73
|
-
*
|
|
92
|
+
* Builds a fixture FileMap that includes the three surviving managed hook
|
|
93
|
+
* scripts — matching what S1's build.mjs emits once
|
|
94
|
+
* `.agents/hooks/rules-injector.sh` is deleted (planHookEmissions directory-
|
|
95
|
+
* scans `.agents/hooks/`, so a deleted source simply stops appearing here).
|
|
96
|
+
* rules-injector.sh is deliberately NOT included: a post-S1 fetch never ships it.
|
|
74
97
|
*/
|
|
75
98
|
function buildFixtureFileMap() {
|
|
76
99
|
const map = {};
|
|
@@ -84,8 +107,6 @@ function buildFixtureFileMap() {
|
|
|
84
107
|
'#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# PostToolUse hook.\nexit 0\n';
|
|
85
108
|
map['claude/hooks/uncommitted-check.sh'] =
|
|
86
109
|
'#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# Stop hook.\nexit 0\n';
|
|
87
|
-
map['claude/hooks/rules-injector.sh'] =
|
|
88
|
-
'#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart hook.\nexit 0\n';
|
|
89
110
|
return map;
|
|
90
111
|
}
|
|
91
112
|
|
|
@@ -119,6 +140,55 @@ function setupInitializedRepo(consumerDir, opts = {}) {
|
|
|
119
140
|
writeFileSync(path.join(agentsDir, 'define.md'), fixtureContent, 'utf8');
|
|
120
141
|
}
|
|
121
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Simulates a consumer who ran `update` before S1 shipped: their
|
|
145
|
+
* settings.json already carries a managed (HOOK_TAG-tagged) SessionStart
|
|
146
|
+
* registration for rules-injector.sh, wired the normal way (buildManagedEntry
|
|
147
|
+
* shape in src/settings.mjs).
|
|
148
|
+
*/
|
|
149
|
+
function writePreS1SettingsWithRulesInjector(consumerDir) {
|
|
150
|
+
const settingsDir = path.join(consumerDir, '.claude');
|
|
151
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
152
|
+
const settings = {
|
|
153
|
+
hooks: {
|
|
154
|
+
SessionStart: [
|
|
155
|
+
{
|
|
156
|
+
[HOOK_TAG]: true,
|
|
157
|
+
matcher: '.*',
|
|
158
|
+
hooks: [{ type: 'command', command: RETIRED_COMMAND }],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
writeFileSync(
|
|
164
|
+
path.join(settingsDir, 'settings.json'),
|
|
165
|
+
JSON.stringify(settings, null, 2) + '\n',
|
|
166
|
+
'utf8',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Captures all output written to process.stderr.write during the execution of
|
|
172
|
+
* `fn`. Returns the concatenated string.
|
|
173
|
+
*
|
|
174
|
+
* Matches the same capture pattern used in first-sync-notice.test.mjs /
|
|
175
|
+
* root-target.test.mjs.
|
|
176
|
+
*/
|
|
177
|
+
async function captureStderr(fn) {
|
|
178
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
179
|
+
let captured = '';
|
|
180
|
+
process.stderr.write = (chunk) => {
|
|
181
|
+
captured += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
await fn();
|
|
186
|
+
} finally {
|
|
187
|
+
process.stderr.write = origWrite;
|
|
188
|
+
}
|
|
189
|
+
return captured;
|
|
190
|
+
}
|
|
191
|
+
|
|
122
192
|
/** Collects every hook `command` string registered anywhere in settings.hooks. */
|
|
123
193
|
function allHookCommands(settings) {
|
|
124
194
|
const hooks = settings.hooks ?? {};
|
|
@@ -136,34 +206,36 @@ function allHookCommands(settings) {
|
|
|
136
206
|
}
|
|
137
207
|
|
|
138
208
|
// ---------------------------------------------------------------------------
|
|
139
|
-
// R1 — DEFAULT_HOOK_SCRIPTS registers rules-injector.sh
|
|
209
|
+
// R1 — DEFAULT_HOOK_SCRIPTS no longer registers rules-injector.sh
|
|
140
210
|
// ---------------------------------------------------------------------------
|
|
141
211
|
|
|
142
|
-
describe('
|
|
143
|
-
it('R1a: DEFAULT_HOOK_SCRIPTS has
|
|
144
|
-
const entry = DEFAULT_HOOK_SCRIPTS.find(
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
assert.ok(
|
|
212
|
+
describe('S1 R1 — DEFAULT_HOOK_SCRIPTS no longer includes rules-injector.sh (retired)', () => {
|
|
213
|
+
it('R1a: DEFAULT_HOOK_SCRIPTS has no entry whose command is .claude/hooks/rules-injector.sh', () => {
|
|
214
|
+
const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.command === RETIRED_COMMAND);
|
|
215
|
+
assert.equal(
|
|
148
216
|
entry,
|
|
149
|
-
|
|
217
|
+
undefined,
|
|
218
|
+
`DEFAULT_HOOK_SCRIPTS must no longer register ${RETIRED_COMMAND} (retired by S1); ` +
|
|
150
219
|
`got commands: ${DEFAULT_HOOK_SCRIPTS.map((h) => h.command).join(', ')}`,
|
|
151
220
|
);
|
|
152
221
|
});
|
|
153
222
|
|
|
154
|
-
it('R1b:
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
223
|
+
it('R1b: DEFAULT_HOOK_SCRIPTS registers exactly the three surviving hooks', () => {
|
|
224
|
+
const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command).sort();
|
|
225
|
+
const expected = [
|
|
226
|
+
'.claude/hooks/managed-file-guard.sh',
|
|
227
|
+
'.claude/hooks/steering-injector.sh',
|
|
228
|
+
'.claude/hooks/uncommitted-check.sh',
|
|
229
|
+
].sort();
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
commands,
|
|
232
|
+
expected,
|
|
233
|
+
`DEFAULT_HOOK_SCRIPTS must register exactly the three surviving hooks, no more, no fewer; ` +
|
|
234
|
+
`got: ${commands.join(', ')}`,
|
|
163
235
|
);
|
|
164
236
|
});
|
|
165
237
|
|
|
166
|
-
it('R1c: the three pre-existing managed hooks are still registered (
|
|
238
|
+
it('R1c: the three pre-existing managed hooks are still registered (retirement is subtractive only, not destructive)', () => {
|
|
167
239
|
const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
|
|
168
240
|
for (const expected of [
|
|
169
241
|
'.claude/hooks/steering-injector.sh',
|
|
@@ -172,17 +244,17 @@ describe('S2 R1 — DEFAULT_HOOK_SCRIPTS includes rules-injector.sh', () => {
|
|
|
172
244
|
]) {
|
|
173
245
|
assert.ok(
|
|
174
246
|
commands.includes(expected),
|
|
175
|
-
`
|
|
247
|
+
`retiring rules-injector.sh must not drop the existing ${expected} registration`,
|
|
176
248
|
);
|
|
177
249
|
}
|
|
178
250
|
});
|
|
179
251
|
});
|
|
180
252
|
|
|
181
253
|
// ---------------------------------------------------------------------------
|
|
182
|
-
// R2 — init wires
|
|
254
|
+
// R2 — init never wires rules-injector.sh into settings.json
|
|
183
255
|
// ---------------------------------------------------------------------------
|
|
184
256
|
|
|
185
|
-
describe('
|
|
257
|
+
describe('S1 R2 — init does not wire rules-injector.sh into settings.json (retired)', () => {
|
|
186
258
|
let consumerDir;
|
|
187
259
|
|
|
188
260
|
beforeEach(() => {
|
|
@@ -193,7 +265,7 @@ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
|
|
|
193
265
|
rmSync(consumerDir, { recursive: true, force: true });
|
|
194
266
|
});
|
|
195
267
|
|
|
196
|
-
it('R2a: init
|
|
268
|
+
it('R2a: init does not register a SessionStart entry for rules-injector.sh', async () => {
|
|
197
269
|
await runInit(consumerDir, {
|
|
198
270
|
targets: ['claude'],
|
|
199
271
|
fetcher: makeFixtureFetcher(),
|
|
@@ -211,34 +283,25 @@ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
|
|
|
211
283
|
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
|
|
212
284
|
);
|
|
213
285
|
assert.ok(
|
|
214
|
-
found,
|
|
215
|
-
'settings.json must contain a SessionStart hook entry for rules-injector.sh after init',
|
|
286
|
+
!found,
|
|
287
|
+
'settings.json must NOT contain a SessionStart hook entry for rules-injector.sh after init (retired)',
|
|
216
288
|
);
|
|
217
289
|
});
|
|
218
290
|
|
|
219
|
-
it('R2b:
|
|
291
|
+
it('R2b: init does not install .claude/hooks/rules-injector.sh on disk', async () => {
|
|
220
292
|
await runInit(consumerDir, {
|
|
221
293
|
targets: ['claude'],
|
|
222
294
|
fetcher: makeFixtureFetcher(),
|
|
223
295
|
availableTags: AVAILABLE_TAGS,
|
|
224
296
|
});
|
|
225
297
|
|
|
226
|
-
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
227
|
-
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
228
|
-
const sessionStart = settings.hooks?.SessionStart ?? [];
|
|
229
|
-
const taggedEntry = sessionStart.find(
|
|
230
|
-
(e) =>
|
|
231
|
-
e[HOOK_TAG] === true &&
|
|
232
|
-
Array.isArray(e.hooks) &&
|
|
233
|
-
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
|
|
234
|
-
);
|
|
235
298
|
assert.ok(
|
|
236
|
-
|
|
237
|
-
|
|
299
|
+
!existsSync(path.join(consumerDir, '.claude', 'hooks', 'rules-injector.sh')),
|
|
300
|
+
'init must not write .claude/hooks/rules-injector.sh — the fetched FileMap no longer ships it (retired)',
|
|
238
301
|
);
|
|
239
302
|
});
|
|
240
303
|
|
|
241
|
-
it('R2c:
|
|
304
|
+
it('R2c: the three surviving managed hooks are still wired by init (additive behavior unaffected by retirement)', async () => {
|
|
242
305
|
await runInit(consumerDir, {
|
|
243
306
|
targets: ['claude'],
|
|
244
307
|
fetcher: makeFixtureFetcher(),
|
|
@@ -249,25 +312,24 @@ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
|
|
|
249
312
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
250
313
|
const commands = allHookCommands(settings).map((c) => c.command);
|
|
251
314
|
|
|
252
|
-
for (const expected of [
|
|
253
|
-
'steering-injector.sh',
|
|
254
|
-
'managed-file-guard.sh',
|
|
255
|
-
'uncommitted-check.sh',
|
|
256
|
-
'rules-injector.sh',
|
|
257
|
-
]) {
|
|
315
|
+
for (const expected of ['steering-injector.sh', 'managed-file-guard.sh', 'uncommitted-check.sh']) {
|
|
258
316
|
assert.ok(
|
|
259
317
|
commands.some((cmd) => cmd.includes(expected)),
|
|
260
|
-
`settings.json must register ${expected}
|
|
318
|
+
`settings.json must still register ${expected} after init`,
|
|
261
319
|
);
|
|
262
320
|
}
|
|
321
|
+
assert.ok(
|
|
322
|
+
!commands.some((cmd) => cmd.includes('rules-injector.sh')),
|
|
323
|
+
'settings.json must not register rules-injector.sh after init',
|
|
324
|
+
);
|
|
263
325
|
});
|
|
264
326
|
});
|
|
265
327
|
|
|
266
328
|
// ---------------------------------------------------------------------------
|
|
267
|
-
// R3 — update
|
|
329
|
+
// R3 — update PRUNES a pre-S1 consumer's existing rules-injector.sh entry
|
|
268
330
|
// ---------------------------------------------------------------------------
|
|
269
331
|
|
|
270
|
-
describe('
|
|
332
|
+
describe('S1 R3 — update prunes a pre-existing rules-injector.sh registration (migration)', () => {
|
|
271
333
|
let consumerDir;
|
|
272
334
|
|
|
273
335
|
beforeEach(() => {
|
|
@@ -278,8 +340,9 @@ describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
|
|
|
278
340
|
rmSync(consumerDir, { recursive: true, force: true });
|
|
279
341
|
});
|
|
280
342
|
|
|
281
|
-
it('R3a:
|
|
343
|
+
it('R3a: a consumer with a managed rules-injector.sh SessionStart entry from before S1 has it pruned by update', async () => {
|
|
282
344
|
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
345
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
283
346
|
|
|
284
347
|
await runUpdate(consumerDir, {
|
|
285
348
|
fetcher: makeFixtureFetcher(),
|
|
@@ -287,41 +350,47 @@ describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
|
|
|
287
350
|
});
|
|
288
351
|
|
|
289
352
|
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
290
|
-
assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after update');
|
|
291
|
-
|
|
292
353
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
293
354
|
const sessionStart = settings.hooks?.SessionStart ?? [];
|
|
294
|
-
const
|
|
355
|
+
const stillPresent = sessionStart.some(
|
|
295
356
|
(e) =>
|
|
296
357
|
Array.isArray(e.hooks) &&
|
|
297
358
|
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
|
|
298
359
|
);
|
|
299
360
|
assert.ok(
|
|
300
|
-
|
|
301
|
-
'
|
|
361
|
+
!stillPresent,
|
|
362
|
+
'update must prune the pre-S1 managed rules-injector.sh SessionStart entry; ' +
|
|
363
|
+
`got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
|
|
302
364
|
);
|
|
303
365
|
});
|
|
304
366
|
|
|
305
|
-
it('R3b:
|
|
367
|
+
it('R3b: pruning the retired entry does not disturb the three surviving hooks getting wired', async () => {
|
|
306
368
|
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
369
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
307
370
|
|
|
308
371
|
await runUpdate(consumerDir, {
|
|
309
372
|
fetcher: makeFixtureFetcher(),
|
|
310
373
|
availableTags: AVAILABLE_TAGS,
|
|
311
374
|
});
|
|
312
375
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
376
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
377
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
378
|
+
const commands = allHookCommands(settings).map((c) => c.command);
|
|
379
|
+
|
|
380
|
+
for (const expected of ['steering-injector.sh', 'managed-file-guard.sh', 'uncommitted-check.sh']) {
|
|
381
|
+
assert.ok(
|
|
382
|
+
commands.some((cmd) => cmd.includes(expected)),
|
|
383
|
+
`settings.json must register ${expected} after update, alongside the rules-injector.sh prune`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
317
386
|
});
|
|
318
387
|
});
|
|
319
388
|
|
|
320
389
|
// ---------------------------------------------------------------------------
|
|
321
|
-
// R4 —
|
|
390
|
+
// R4 — repeated update never reintroduces the pruned registration
|
|
322
391
|
// ---------------------------------------------------------------------------
|
|
323
392
|
|
|
324
|
-
describe('
|
|
393
|
+
describe('S1 R4 — repeated update leaves the rules-injector.sh registration pruned (idempotent removal)', () => {
|
|
325
394
|
let consumerDir;
|
|
326
395
|
|
|
327
396
|
beforeEach(() => {
|
|
@@ -332,8 +401,9 @@ describe('S2 R4 — repeated update produces no duplicate rules-injector.sh regi
|
|
|
332
401
|
rmSync(consumerDir, { recursive: true, force: true });
|
|
333
402
|
});
|
|
334
403
|
|
|
335
|
-
it('R4: rules-injector.sh command
|
|
404
|
+
it('R4: rules-injector.sh command is absent after two updates starting from a pre-S1 tagged entry', async () => {
|
|
336
405
|
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
406
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
337
407
|
|
|
338
408
|
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
339
409
|
await runUpdate(consumerDir, opts);
|
|
@@ -344,9 +414,378 @@ describe('S2 R4 — repeated update produces no duplicate rules-injector.sh regi
|
|
|
344
414
|
const commands = allHookCommands(settings).map((c) => c.command);
|
|
345
415
|
const occurrences = commands.filter((cmd) => cmd.includes('rules-injector.sh')).length;
|
|
346
416
|
|
|
417
|
+
assert.equal(
|
|
418
|
+
occurrences,
|
|
419
|
+
0,
|
|
420
|
+
`rules-injector.sh hook command must be absent (pruned, never reintroduced) after two updates ` +
|
|
421
|
+
`(found: ${occurrences})`,
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// S1 cycle 2 RED — deliver-validate finding H2 (major).
|
|
428
|
+
//
|
|
429
|
+
// mergeHookSettings returns `prunedRetired`, and all three call sites in
|
|
430
|
+
// update.mjs / init.mjs discard it — nothing is ever printed when a retired
|
|
431
|
+
// hook registration is pruned from a consumer's settings.json. Meanwhile the
|
|
432
|
+
// FILE-orphan prune two blocks below in update.mjs DOES emit an
|
|
433
|
+
// "info: dev-agents-sync: pruned N orphaned managed file(s)" line
|
|
434
|
+
// (update.mjs:269-272) — so the silence on the hook-registration prune is an
|
|
435
|
+
// inconsistency within one function, not house style.
|
|
436
|
+
//
|
|
437
|
+
// Worse: `runUpdate --dry-run` returns at update.mjs:187-202 BEFORE
|
|
438
|
+
// mergeHookSettings is ever called, so the planned prune is never computed
|
|
439
|
+
// or reported at all — while README.md:129's "Self-cleaning" bullet
|
|
440
|
+
// advertises the behavior unconditionally.
|
|
441
|
+
//
|
|
442
|
+
// Intended fix (not yet implemented): `update` (and `init`) report the prune
|
|
443
|
+
// on stderr, in the same "info: dev-agents-sync: ..." style as the existing
|
|
444
|
+
// orphan-prune line, when prunedRetired.length > 0; and `update --dry-run`
|
|
445
|
+
// computes and surfaces the planned prune (result.prunedRetired) instead of
|
|
446
|
+
// skipping the computation.
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
describe('S1 H2 — update reports the retired-hook-registration prune (not silent)', () => {
|
|
450
|
+
let consumerDir;
|
|
451
|
+
|
|
452
|
+
beforeEach(() => {
|
|
453
|
+
consumerDir = makeTmpDir();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
afterEach(() => {
|
|
457
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('H2a: runUpdate emits an info: dev-agents-sync: line naming the retired-hook prune when one occurs', async () => {
|
|
461
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
462
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
463
|
+
|
|
464
|
+
const stderr = await captureStderr(() =>
|
|
465
|
+
runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
assert.match(
|
|
469
|
+
stderr,
|
|
470
|
+
/info: dev-agents-sync:.*(prun|remov)[a-z]*.*(hook|registration|rules-injector)/i,
|
|
471
|
+
'runUpdate must report the retired-hook-registration prune on stderr, in the same ' +
|
|
472
|
+
'"info: dev-agents-sync: ..." style as the orphaned-file-prune line (update.mjs:269-272), ' +
|
|
473
|
+
`not silently discard mergeHookSettings’s prunedRetired result; got stderr:\n${stderr || '(empty)'}`,
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('H2b: update --dry-run computes and surfaces the planned retired-hook prune instead of skipping it', async () => {
|
|
478
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
479
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
480
|
+
|
|
481
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
482
|
+
const before = readFileSync(settingsPath, 'utf8');
|
|
483
|
+
|
|
484
|
+
const result = await runUpdate(consumerDir, {
|
|
485
|
+
dryRun: true,
|
|
486
|
+
fetcher: makeFixtureFetcher(),
|
|
487
|
+
availableTags: AVAILABLE_TAGS,
|
|
488
|
+
});
|
|
489
|
+
|
|
347
490
|
assert.ok(
|
|
348
|
-
|
|
349
|
-
|
|
491
|
+
Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
|
|
492
|
+
'--dry-run must compute and surface the planned retired-hook-registration prune ' +
|
|
493
|
+
'(result.prunedRetired) rather than returning before mergeHookSettings is ever called ' +
|
|
494
|
+
`(README.md:129’s "Self-cleaning" bullet must hold under --dry-run too); got: ${JSON.stringify(result)}`,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const after = readFileSync(settingsPath, 'utf8');
|
|
498
|
+
assert.equal(after, before, '--dry-run must never write to settings.json');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('H2c: no prune line is emitted when there is nothing to prune (guards against a spurious message every run)', async () => {
|
|
502
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
503
|
+
// settings.json is already fully wired to the three surviving hooks, with
|
|
504
|
+
// no retired registration anywhere — nothing to prune.
|
|
505
|
+
const settingsDir = path.join(consumerDir, '.claude');
|
|
506
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
507
|
+
writeFileSync(
|
|
508
|
+
path.join(settingsDir, 'settings.json'),
|
|
509
|
+
JSON.stringify(
|
|
510
|
+
{
|
|
511
|
+
hooks: {
|
|
512
|
+
SessionStart: [
|
|
513
|
+
{
|
|
514
|
+
[HOOK_TAG]: true,
|
|
515
|
+
matcher: '.*',
|
|
516
|
+
hooks: [{ type: 'command', command: '.claude/hooks/steering-injector.sh' }],
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
PostToolUse: [
|
|
520
|
+
{
|
|
521
|
+
[HOOK_TAG]: true,
|
|
522
|
+
matcher: '.*',
|
|
523
|
+
hooks: [{ type: 'command', command: '.claude/hooks/managed-file-guard.sh' }],
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
Stop: [
|
|
527
|
+
{
|
|
528
|
+
[HOOK_TAG]: true,
|
|
529
|
+
matcher: '.*',
|
|
530
|
+
hooks: [{ type: 'command', command: '.claude/hooks/uncommitted-check.sh' }],
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
null,
|
|
536
|
+
2,
|
|
537
|
+
) + '\n',
|
|
538
|
+
'utf8',
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const stderr = await captureStderr(() =>
|
|
542
|
+
runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
assert.doesNotMatch(
|
|
546
|
+
stderr,
|
|
547
|
+
/rules-injector|retired hook|hook registration/i,
|
|
548
|
+
`no retired-hook-registration prune message must be emitted when nothing was pruned ` +
|
|
549
|
+
`(guards against a spurious message on every run); got stderr:\n${stderr}`,
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
// S1 cycle 3 — closing deferred Minor/Low findings from deliver-validate.
|
|
556
|
+
//
|
|
557
|
+
// M2 (Group A — regression guard): cycle 2 removed the `hookSpec.length > 0`
|
|
558
|
+
// guard from all three settings-wiring call sites (update.mjs:149-154,
|
|
559
|
+
// update.mjs:254-256, init.mjs:206-209) so a consumer with a stale retired
|
|
560
|
+
// registration but NO hook scripts on disk still gets pruned. Validate's exact
|
|
561
|
+
// words: "re-adding the guard at all three sites would leave both suites
|
|
562
|
+
// green" — nothing pinned it before this test. This exercises the
|
|
563
|
+
// up-to-date short-circuit branch specifically, since that is the one branch
|
|
564
|
+
// that derives its hook spec from files present ON DISK (update.mjs:149-151)
|
|
565
|
+
// rather than from the fetched FileMap, so it is the only branch where "no
|
|
566
|
+
// hook scripts on disk" naturally produces an empty spec.
|
|
567
|
+
//
|
|
568
|
+
// M3 (Group A — regression guard): R2a/R2b only assert init never ADDS the
|
|
569
|
+
// retired registration; nothing asserted it REMOVES a pre-existing one.
|
|
570
|
+
// init.mjs:207-208 justifies its unconditional wiring call by claiming init
|
|
571
|
+
// run over an existing .claude/ can meet a pre-retirement settings.json —
|
|
572
|
+
// e.g. a second `init` re-run, or a consumer who lost their lockfile. That
|
|
573
|
+
// claim was unpinned before this test.
|
|
574
|
+
//
|
|
575
|
+
// M1 (Group B — RED): init.mjs:135-155 returns before any hook wiring is
|
|
576
|
+
// computed, so `runInit --dry-run`'s result never carries a `prunedRetired`
|
|
577
|
+
// field at all — update.mjs:196-198 explicitly calls out that under-reporting
|
|
578
|
+
// "a real, intended change to consumer-owned state" as the reason update's
|
|
579
|
+
// dry-run computes it. init's write path is correct (see M3); only its PLAN
|
|
580
|
+
// is incomplete.
|
|
581
|
+
//
|
|
582
|
+
// L5 (Group B — RED): hooks.mjs:69-73 prints `prunedRetired.length`, but
|
|
583
|
+
// settings.mjs:220 dedupes that list with `[...new Set(pruned)]`. So the same
|
|
584
|
+
// retired command registered under TWO event buckets is removed twice but
|
|
585
|
+
// reported as pruning only 1 distinct command.
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
describe('S1 M2 — update prunes a retired registration on the up-to-date short-circuit even with no hook scripts on disk', () => {
|
|
589
|
+
let consumerDir;
|
|
590
|
+
|
|
591
|
+
beforeEach(() => {
|
|
592
|
+
consumerDir = makeTmpDir();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
afterEach(() => {
|
|
596
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('M2: an up-to-date consumer with a pre-S1 tagged rules-injector.sh entry and an empty .claude/hooks/ dir still gets it pruned', async () => {
|
|
600
|
+
// Pin resolvedVersion to the highest tag AVAILABLE_TAGS/`^1` resolves to
|
|
601
|
+
// (1.2.0) so update takes the up-to-date short-circuit (update.mjs:140-164)
|
|
602
|
+
// rather than fetching. That branch filters DEFAULT_HOOK_SCRIPTS down to
|
|
603
|
+
// commands that `existsSync` on disk BEFORE calling wireHookSettings — with
|
|
604
|
+
// no hooks installed, that filtered spec is empty. The prune must still run
|
|
605
|
+
// because it was deliberately made unconditional on the spec's length
|
|
606
|
+
// (update.mjs:152-154's comment); re-adding a `hookSpec.length > 0` guard
|
|
607
|
+
// would make this a silent no-op and fail the assertions below.
|
|
608
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.2.0' });
|
|
609
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
610
|
+
|
|
611
|
+
// No hook scripts on disk anywhere — .claude/hooks/ exists but is empty.
|
|
612
|
+
mkdirSync(path.join(consumerDir, '.claude', 'hooks'), { recursive: true });
|
|
613
|
+
|
|
614
|
+
const result = await runUpdate(consumerDir, {
|
|
615
|
+
fetcher: makeFixtureFetcher(),
|
|
616
|
+
availableTags: AVAILABLE_TAGS,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
assert.equal(
|
|
620
|
+
result.upToDate,
|
|
621
|
+
true,
|
|
622
|
+
'this test must exercise the up-to-date short-circuit (update.mjs:140-164); ' +
|
|
623
|
+
`got: ${JSON.stringify(result)}`,
|
|
624
|
+
);
|
|
625
|
+
assert.ok(
|
|
626
|
+
Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
|
|
627
|
+
`result.prunedRetired must report "${RETIRED_COMMAND}" pruned even though no hook scripts ` +
|
|
628
|
+
`are on disk; got: ${JSON.stringify(result.prunedRetired)}`,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
632
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
633
|
+
const sessionStart = settings.hooks?.SessionStart ?? [];
|
|
634
|
+
const stillPresent = sessionStart.some(
|
|
635
|
+
(e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
|
|
636
|
+
);
|
|
637
|
+
assert.ok(
|
|
638
|
+
!stillPresent,
|
|
639
|
+
`the pre-S1 rules-injector.sh entry must be pruned even though no hook scripts are on disk; ` +
|
|
640
|
+
`got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
describe('S1 M3 — init prunes a pre-existing tagged retired entry (mirrors R3a, but for runInit)', () => {
|
|
646
|
+
let consumerDir;
|
|
647
|
+
|
|
648
|
+
beforeEach(() => {
|
|
649
|
+
consumerDir = makeTmpDir();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
afterEach(() => {
|
|
653
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('M3: runInit prunes a pre-existing managed rules-injector.sh SessionStart entry and reports it in result.prunedRetired', async () => {
|
|
657
|
+
// Simulates a consumer whose .claude/settings.json already carries a
|
|
658
|
+
// pre-retirement managed registration (e.g. a lost lockfile, or a second
|
|
659
|
+
// `init` re-run over an already-hook-wired .claude/) — the scenario
|
|
660
|
+
// init.mjs:207-208's comment claims is possible but that R2a/R2b never
|
|
661
|
+
// exercised (those only prove init never ADDS the retired registration).
|
|
662
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
663
|
+
|
|
664
|
+
const result = await runInit(consumerDir, {
|
|
665
|
+
targets: ['claude'],
|
|
666
|
+
fetcher: makeFixtureFetcher(),
|
|
667
|
+
availableTags: AVAILABLE_TAGS,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
assert.ok(
|
|
671
|
+
Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
|
|
672
|
+
`runInit's result must report "${RETIRED_COMMAND}" pruned; got: ${JSON.stringify(result.prunedRetired)}`,
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
676
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
677
|
+
const sessionStart = settings.hooks?.SessionStart ?? [];
|
|
678
|
+
const stillPresent = sessionStart.some(
|
|
679
|
+
(e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
|
|
680
|
+
);
|
|
681
|
+
assert.ok(
|
|
682
|
+
!stillPresent,
|
|
683
|
+
`init must prune the pre-existing managed rules-injector.sh entry; ` +
|
|
684
|
+
`got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
describe('S1 M1 — init --dry-run computes and reports the planned retired-hook prune', () => {
|
|
690
|
+
let consumerDir;
|
|
691
|
+
|
|
692
|
+
beforeEach(() => {
|
|
693
|
+
consumerDir = makeTmpDir();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
afterEach(() => {
|
|
697
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('M1: runInit --dry-run over a consumer with a pre-existing tagged retired entry reports the planned prune and writes nothing', async () => {
|
|
701
|
+
writePreS1SettingsWithRulesInjector(consumerDir);
|
|
702
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
703
|
+
const before = readFileSync(settingsPath, 'utf8');
|
|
704
|
+
|
|
705
|
+
const result = await runInit(consumerDir, {
|
|
706
|
+
dryRun: true,
|
|
707
|
+
targets: ['claude'],
|
|
708
|
+
fetcher: makeFixtureFetcher(),
|
|
709
|
+
availableTags: AVAILABLE_TAGS,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
assert.ok(
|
|
713
|
+
Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
|
|
714
|
+
'runInit --dry-run must compute and report the planned retired-hook-registration prune ' +
|
|
715
|
+
`(mirroring update's prunedRetired field name), not skip it because init.mjs:135-155 ` +
|
|
716
|
+
`returns before any hook wiring is computed; got: ${JSON.stringify(result)}`,
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
const after = readFileSync(settingsPath, 'utf8');
|
|
720
|
+
assert.equal(after, before, '--dry-run must never write to settings.json');
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
describe('S1 L5 — the prune info line counts registrations removed, not distinct commands', () => {
|
|
725
|
+
let consumerDir;
|
|
726
|
+
|
|
727
|
+
beforeEach(() => {
|
|
728
|
+
consumerDir = makeTmpDir();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
afterEach(() => {
|
|
732
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('L5: a retired command registered under two event buckets is reported as 2 registrations pruned, not deduped to 1', async () => {
|
|
736
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
737
|
+
|
|
738
|
+
// The SAME retired command, tagged, registered under TWO different event
|
|
739
|
+
// buckets — settings.mjs:220 dedupes prunedRetired with
|
|
740
|
+
// `[...new Set(pruned)]` before hooks.mjs reports its length, so this
|
|
741
|
+
// removes TWO registrations but (today) is reported as pruning only 1
|
|
742
|
+
// distinct command.
|
|
743
|
+
const settingsDir = path.join(consumerDir, '.claude');
|
|
744
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
745
|
+
writeFileSync(
|
|
746
|
+
path.join(settingsDir, 'settings.json'),
|
|
747
|
+
JSON.stringify(
|
|
748
|
+
{
|
|
749
|
+
hooks: {
|
|
750
|
+
SessionStart: [
|
|
751
|
+
{
|
|
752
|
+
[HOOK_TAG]: true,
|
|
753
|
+
matcher: '.*',
|
|
754
|
+
hooks: [{ type: 'command', command: RETIRED_COMMAND }],
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
PostToolUse: [
|
|
758
|
+
{
|
|
759
|
+
[HOOK_TAG]: true,
|
|
760
|
+
matcher: '.*',
|
|
761
|
+
hooks: [{ type: 'command', command: RETIRED_COMMAND }],
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
null,
|
|
767
|
+
2,
|
|
768
|
+
) + '\n',
|
|
769
|
+
'utf8',
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const stderr = await captureStderr(() =>
|
|
773
|
+
runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
// Assert on the emitted MESSAGE (not on settings.mjs's internal array shape)
|
|
777
|
+
// so a fix is free to report the count however it likes internally, as long
|
|
778
|
+
// as the number a consumer reads on their terminal is correct.
|
|
779
|
+
const match = stderr.match(/(\d+)\D{0,40}regist/i);
|
|
780
|
+
assert.ok(
|
|
781
|
+
match,
|
|
782
|
+
`stderr must report a registration count for the prune; got stderr:\n${stderr || '(empty)'}`,
|
|
783
|
+
);
|
|
784
|
+
assert.equal(
|
|
785
|
+
match[1],
|
|
786
|
+
'2',
|
|
787
|
+
'the prune info line must report 2 registrations removed (one per event bucket the retired ' +
|
|
788
|
+
`command was registered under), not the deduped distinct-command count (1); got stderr:\n${stderr}`,
|
|
350
789
|
);
|
|
351
790
|
});
|
|
352
791
|
});
|