@dalzoubi/dev-agents-sync 2.0.28 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -8
- package/package.json +1 -1
- package/src/commands/init.mjs +26 -2
- package/src/commands/update.mjs +45 -3
- package/src/hooks.mjs +52 -5
- package/src/settings.mjs +17 -0
- package/tests/e2e/steering-hooks-flow.test.mjs +27 -19
- package/tests/hooks-summary-line.test.mjs +595 -0
- package/tests/hooks-wave2-registration.test.mjs +523 -0
- package/tests/rules-injector-registration.test.mjs +17 -14
- package/tests/update-uptodate-wiring.test.mjs +484 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/hooks-wave2-registration.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Slice S5 — CLI wiring for the 260805 hooks wave.
|
|
5
|
+
* Spec: docs/specs/260805-hooks-wave-2/
|
|
6
|
+
*
|
|
7
|
+
* The wave adds five hooks — three BLOCKING (PreToolUse, exit 2) and two
|
|
8
|
+
* warning — to a pipeline that previously carried only non-blocking reminders.
|
|
9
|
+
* Nothing in the CLI changed to carry them: `collectHookPaths` /
|
|
10
|
+
* `hookScriptsForInstalled` are predicate-driven, so the whole slice is five
|
|
11
|
+
* rows in `DEFAULT_HOOK_SCRIPTS`. That is precisely why it needs tests: a wrong
|
|
12
|
+
* event or matcher installs a hook that never fires (or fires on every tool
|
|
13
|
+
* call), and nothing else in the suite would notice.
|
|
14
|
+
*
|
|
15
|
+
* Behavior anchors:
|
|
16
|
+
*
|
|
17
|
+
* W1. Each of the five hooks is registered on the event and matcher the plan's
|
|
18
|
+
* registration table specifies.
|
|
19
|
+
* W2. `init` and `update` install and wire every registered hook — asserted
|
|
20
|
+
* against the registration table, not a pinned count, so a hook added
|
|
21
|
+
* later cannot slip through uncovered.
|
|
22
|
+
* W3. The settings merge stays idempotent with the larger table: a second
|
|
23
|
+
* `update` leaves settings.json byte-identical (twice == once).
|
|
24
|
+
* W4. A pre-wave lockfile (recording only the three 260616 hooks) upgrades
|
|
25
|
+
* additively — no error, and the new hooks land.
|
|
26
|
+
* W5. `check` reports drift (exit 1) when a wave-2 hook script is modified.
|
|
27
|
+
*
|
|
28
|
+
* The wave-2 hooks' own runtime behavior (deny/allow/bypass/fail-open) is
|
|
29
|
+
* proven by the content repo's `tests/hooks-*.test.mjs`; the fixture bodies
|
|
30
|
+
* here are inert stand-ins, since this file is about wiring, not detection.
|
|
31
|
+
*
|
|
32
|
+
* Fixture pattern mirrors tests/hooks-cli-install.test.mjs (release-v1.0.0
|
|
33
|
+
* FileMap + injected hook entries carrying the shell-comment marker).
|
|
34
|
+
*
|
|
35
|
+
* Test runner: `node --test packages/dev-agents-sync/tests/hooks-wave2-registration.test.mjs`
|
|
36
|
+
* i18n/accessibility: N/A — CLI-only, no UI surface and no translated strings.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
40
|
+
import assert from 'node:assert/strict';
|
|
41
|
+
import {
|
|
42
|
+
mkdtempSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
writeFileSync,
|
|
45
|
+
readFileSync,
|
|
46
|
+
existsSync,
|
|
47
|
+
rmSync,
|
|
48
|
+
readdirSync,
|
|
49
|
+
} from 'node:fs';
|
|
50
|
+
import { tmpdir } from 'node:os';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { fileURLToPath } from 'node:url';
|
|
53
|
+
|
|
54
|
+
import { runInit } from '../src/commands/init.mjs';
|
|
55
|
+
import { runUpdate } from '../src/commands/update.mjs';
|
|
56
|
+
import { runCheck } from '../src/commands/check.mjs';
|
|
57
|
+
import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Paths / fixtures
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
65
|
+
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
|
|
66
|
+
const LOCKFILE_NAME = '.dev-agents-sync.json';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The registration table this slice adds, restated from
|
|
70
|
+
* docs/specs/260805-hooks-wave-2/plan.md.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately a literal: this is the CONTRACT under test (which event fires
|
|
73
|
+
* which hook, and on which tools), not an inventory of what happens to exist.
|
|
74
|
+
* Deriving it from DEFAULT_HOOK_SCRIPTS would assert the table equals itself.
|
|
75
|
+
* It stays valid as later waves append rows — nothing here claims the table
|
|
76
|
+
* holds ONLY these five.
|
|
77
|
+
*/
|
|
78
|
+
const WAVE2_REGISTRATIONS = [
|
|
79
|
+
{ command: '.claude/hooks/force-push-guard.sh', event: 'PreToolUse', matcher: 'Bash' },
|
|
80
|
+
{ command: '.claude/hooks/spec-approval-gate.sh', event: 'PreToolUse', matcher: 'Edit|Write' },
|
|
81
|
+
{ command: '.claude/hooks/handoff-state-guard.sh', event: 'PreToolUse', matcher: 'Write' },
|
|
82
|
+
{ command: '.claude/hooks/spec-drift-warn.sh', event: 'PostToolUse', matcher: 'Edit|Write' },
|
|
83
|
+
{ command: '.claude/hooks/pre-pr-preflight.sh', event: 'PreToolUse', matcher: 'Bash' },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
87
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
88
|
+
const full = path.join(currentDir, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
collectFiles(baseDir, full, map, prefix);
|
|
91
|
+
} else {
|
|
92
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
93
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A fetched FileMap carrying one hook script per registration — the shape the
|
|
100
|
+
* build emits (`claude/hooks/<name>.sh`, shell-comment marker, since the CLI
|
|
101
|
+
* keys managed hooks off that prefix).
|
|
102
|
+
*/
|
|
103
|
+
function buildFixtureFileMap() {
|
|
104
|
+
const map = {};
|
|
105
|
+
const targetDir = path.join(FIXTURE_DIR, 'claude');
|
|
106
|
+
if (existsSync(targetDir)) collectFiles(targetDir, targetDir, map, 'claude');
|
|
107
|
+
for (const { event, command } of DEFAULT_HOOK_SCRIPTS) {
|
|
108
|
+
map[command.replace(/^\.claude\//, 'claude/')] =
|
|
109
|
+
`#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event} hook fixture: ${path.basename(command)}.\nexit 0\n`;
|
|
110
|
+
}
|
|
111
|
+
return map;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function makeFixtureFetcher() {
|
|
115
|
+
return async (_repo, _tag, _token) => buildFixtureFileMap();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeTmpDir() {
|
|
119
|
+
return mkdtempSync(path.join(tmpdir(), 'das-hooks-wave2-'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Seeds a consumer that last synced BEFORE this wave: the lockfile records only
|
|
124
|
+
* the three 260616 hooks, and only those three exist on disk.
|
|
125
|
+
*/
|
|
126
|
+
function setupPreWaveRepo(consumerDir) {
|
|
127
|
+
const raw =
|
|
128
|
+
JSON.stringify(
|
|
129
|
+
{
|
|
130
|
+
$schema:
|
|
131
|
+
'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json',
|
|
132
|
+
source: 'github:dalzoubi/dev-agents',
|
|
133
|
+
range: '^1',
|
|
134
|
+
resolvedVersion: '1.0.0',
|
|
135
|
+
targets: ['claude'],
|
|
136
|
+
lastUpdated: '2026-06-01T00:00:00Z',
|
|
137
|
+
hooks: [
|
|
138
|
+
'.claude/hooks/managed-file-guard.sh',
|
|
139
|
+
'.claude/hooks/steering-injector.sh',
|
|
140
|
+
'.claude/hooks/uncommitted-check.sh',
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
null,
|
|
144
|
+
2,
|
|
145
|
+
) + '\n';
|
|
146
|
+
writeFileSync(path.join(consumerDir, LOCKFILE_NAME), raw, 'utf8');
|
|
147
|
+
|
|
148
|
+
const agentsDir = path.join(consumerDir, '.claude', 'agents');
|
|
149
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
150
|
+
writeFileSync(
|
|
151
|
+
path.join(agentsDir, 'define.md'),
|
|
152
|
+
readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
|
|
153
|
+
'utf8',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every CLI-managed registration in settings.json, flattened to {event, matcher, command}. */
|
|
158
|
+
function managedRegistrations(consumerDir) {
|
|
159
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
160
|
+
if (!existsSync(settingsPath)) return [];
|
|
161
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
164
|
+
if (!Array.isArray(entries)) continue;
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (entry?.[HOOK_TAG] !== true) continue;
|
|
167
|
+
for (const h of entry.hooks ?? []) {
|
|
168
|
+
if (typeof h.command === 'string') {
|
|
169
|
+
out.push({ event, matcher: entry.matcher, command: h.command });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// W1 — the registration table matches the plan
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe('W1 — the five wave-2 hooks are registered on the specified event and matcher', () => {
|
|
182
|
+
for (const { command, event, matcher } of WAVE2_REGISTRATIONS) {
|
|
183
|
+
it(`W1: ${path.basename(command)} is registered on ${event} with matcher "${matcher}"`, () => {
|
|
184
|
+
const entries = DEFAULT_HOOK_SCRIPTS.filter((h) => h.command === command);
|
|
185
|
+
assert.equal(
|
|
186
|
+
entries.length,
|
|
187
|
+
1,
|
|
188
|
+
`${command} must have exactly one DEFAULT_HOOK_SCRIPTS entry; got ${entries.length}`,
|
|
189
|
+
);
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
{ event: entries[0].event, matcher: entries[0].matcher },
|
|
192
|
+
{ event, matcher },
|
|
193
|
+
`${command} must be wired per docs/specs/260805-hooks-wave-2/plan.md. A wrong event ` +
|
|
194
|
+
`means the hook never sees the call it guards; a wrong matcher means it is spawned ` +
|
|
195
|
+
`on unrelated tool calls (or skipped on the ones that matter).`,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
it('W1: every blocking hook is registered on PreToolUse (the only event that can deny)', () => {
|
|
201
|
+
// Exit 2 denies the tool call on PreToolUse and nowhere else — a blocker
|
|
202
|
+
// registered on any other event is inert, and silently so.
|
|
203
|
+
const blockers = [
|
|
204
|
+
'.claude/hooks/force-push-guard.sh',
|
|
205
|
+
'.claude/hooks/spec-approval-gate.sh',
|
|
206
|
+
'.claude/hooks/handoff-state-guard.sh',
|
|
207
|
+
];
|
|
208
|
+
for (const command of blockers) {
|
|
209
|
+
const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.command === command);
|
|
210
|
+
assert.ok(entry, `${command} must be registered`);
|
|
211
|
+
assert.equal(
|
|
212
|
+
entry.event,
|
|
213
|
+
'PreToolUse',
|
|
214
|
+
`${command} blocks by exiting 2, which only denies on PreToolUse; on ${entry.event} it ` +
|
|
215
|
+
`would be a hook that reports failure and lets the call through`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// W2 — init/update install and wire every registered hook
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe('W2 — init and update install and wire every registered hook', () => {
|
|
226
|
+
let consumerDir;
|
|
227
|
+
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
consumerDir = makeTmpDir();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
afterEach(() => {
|
|
233
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('W2a: init installs a script on disk for every registration', async () => {
|
|
237
|
+
await runInit(consumerDir, {
|
|
238
|
+
targets: ['claude'],
|
|
239
|
+
fetcher: makeFixtureFetcher(),
|
|
240
|
+
availableTags: AVAILABLE_TAGS,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const missing = DEFAULT_HOOK_SCRIPTS.map((h) => h.command).filter(
|
|
244
|
+
(c) => !existsSync(path.join(consumerDir, c)),
|
|
245
|
+
);
|
|
246
|
+
assert.deepEqual(missing, [], `init must install every registered hook script; missing: ${missing}`);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('W2b: init wires every registration into settings.json with its event and matcher', async () => {
|
|
250
|
+
await runInit(consumerDir, {
|
|
251
|
+
targets: ['claude'],
|
|
252
|
+
fetcher: makeFixtureFetcher(),
|
|
253
|
+
availableTags: AVAILABLE_TAGS,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const wired = managedRegistrations(consumerDir);
|
|
257
|
+
for (const { event, matcher, command } of DEFAULT_HOOK_SCRIPTS) {
|
|
258
|
+
assert.ok(
|
|
259
|
+
wired.some((w) => w.command === command && w.event === event && w.matcher === matcher),
|
|
260
|
+
`settings.json must wire ${command} on ${event} with matcher "${matcher}"; ` +
|
|
261
|
+
`got: ${JSON.stringify(wired, null, 2)}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('W2c: update wires every registration into settings.json', async () => {
|
|
267
|
+
setupPreWaveRepo(consumerDir);
|
|
268
|
+
await runUpdate(consumerDir, {
|
|
269
|
+
fetcher: makeFixtureFetcher(),
|
|
270
|
+
availableTags: AVAILABLE_TAGS,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const wired = managedRegistrations(consumerDir);
|
|
274
|
+
for (const { event, matcher, command } of DEFAULT_HOOK_SCRIPTS) {
|
|
275
|
+
assert.ok(
|
|
276
|
+
wired.some((w) => w.command === command && w.event === event && w.matcher === matcher),
|
|
277
|
+
`update must wire ${command} on ${event} with matcher "${matcher}"; ` +
|
|
278
|
+
`got: ${JSON.stringify(wired, null, 2)}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('W2d: the two Bash PreToolUse hooks are both wired (a shared event+matcher is not deduped)', async () => {
|
|
284
|
+
// force-push-guard and pre-pr-preflight share PreToolUse/Bash. Merge
|
|
285
|
+
// identity is the COMMAND, so both must survive — collapsing on
|
|
286
|
+
// event+matcher would silently drop one.
|
|
287
|
+
await runInit(consumerDir, {
|
|
288
|
+
targets: ['claude'],
|
|
289
|
+
fetcher: makeFixtureFetcher(),
|
|
290
|
+
availableTags: AVAILABLE_TAGS,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const commands = managedRegistrations(consumerDir)
|
|
294
|
+
.filter((w) => w.event === 'PreToolUse' && w.matcher === 'Bash')
|
|
295
|
+
.map((w) => w.command)
|
|
296
|
+
.sort();
|
|
297
|
+
assert.deepEqual(
|
|
298
|
+
commands,
|
|
299
|
+
['.claude/hooks/force-push-guard.sh', '.claude/hooks/pre-pr-preflight.sh'],
|
|
300
|
+
'both PreToolUse/Bash hooks must be wired independently',
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('W2e: installed hook scripts are recorded in the lockfile', async () => {
|
|
305
|
+
await runInit(consumerDir, {
|
|
306
|
+
targets: ['claude'],
|
|
307
|
+
fetcher: makeFixtureFetcher(),
|
|
308
|
+
availableTags: AVAILABLE_TAGS,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
|
|
312
|
+
for (const { command } of WAVE2_REGISTRATIONS) {
|
|
313
|
+
assert.ok(raw.includes(path.basename(command)), `lockfile must track ${command}`);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// W3 — idempotence with the larger registration table
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
describe('W3 — a second update is a no-op (twice == once)', () => {
|
|
323
|
+
let consumerDir;
|
|
324
|
+
|
|
325
|
+
beforeEach(() => {
|
|
326
|
+
consumerDir = makeTmpDir();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
afterEach(() => {
|
|
330
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('W3a: settings.json is byte-identical after a second update', async () => {
|
|
334
|
+
setupPreWaveRepo(consumerDir);
|
|
335
|
+
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
336
|
+
|
|
337
|
+
await runUpdate(consumerDir, opts);
|
|
338
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
339
|
+
const first = readFileSync(settingsPath, 'utf8');
|
|
340
|
+
|
|
341
|
+
await runUpdate(consumerDir, opts);
|
|
342
|
+
const second = readFileSync(settingsPath, 'utf8');
|
|
343
|
+
|
|
344
|
+
assert.equal(
|
|
345
|
+
second,
|
|
346
|
+
first,
|
|
347
|
+
'a second update must not rewrite settings.json — the merge is idempotent (§9)',
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('W3b: no hook command is registered twice after two updates', async () => {
|
|
352
|
+
setupPreWaveRepo(consumerDir);
|
|
353
|
+
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
354
|
+
|
|
355
|
+
await runUpdate(consumerDir, opts);
|
|
356
|
+
await runUpdate(consumerDir, opts);
|
|
357
|
+
|
|
358
|
+
const commands = managedRegistrations(consumerDir).map((w) => w.command);
|
|
359
|
+
const duplicated = commands.filter((c, i) => commands.indexOf(c) !== i);
|
|
360
|
+
assert.deepEqual(duplicated, [], `duplicate registrations after two updates: ${duplicated}`);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('W3c: hook scripts on disk are byte-identical after a second update', async () => {
|
|
364
|
+
setupPreWaveRepo(consumerDir);
|
|
365
|
+
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
366
|
+
|
|
367
|
+
await runUpdate(consumerDir, opts);
|
|
368
|
+
const first = DEFAULT_HOOK_SCRIPTS.map((h) =>
|
|
369
|
+
readFileSync(path.join(consumerDir, h.command), 'utf8'),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
await runUpdate(consumerDir, opts);
|
|
373
|
+
const second = DEFAULT_HOOK_SCRIPTS.map((h) =>
|
|
374
|
+
readFileSync(path.join(consumerDir, h.command), 'utf8'),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
assert.deepEqual(second, first, 'hook scripts must be byte-identical after a second update');
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// W4 — pre-wave lockfile upgrades additively (§8)
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
describe('W4 — a pre-wave lockfile upgrades additively', () => {
|
|
386
|
+
let consumerDir;
|
|
387
|
+
|
|
388
|
+
beforeEach(() => {
|
|
389
|
+
consumerDir = makeTmpDir();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
afterEach(() => {
|
|
393
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('W4a: update against a lockfile listing only the pre-wave hooks does not throw', async () => {
|
|
397
|
+
setupPreWaveRepo(consumerDir);
|
|
398
|
+
await assert.doesNotReject(
|
|
399
|
+
runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
400
|
+
'a lockfile written before this wave must upgrade without error (§8)',
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('W4b: the upgraded lockfile keeps the pre-wave hooks and gains the new ones', async () => {
|
|
405
|
+
setupPreWaveRepo(consumerDir);
|
|
406
|
+
await runUpdate(consumerDir, {
|
|
407
|
+
fetcher: makeFixtureFetcher(),
|
|
408
|
+
availableTags: AVAILABLE_TAGS,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
|
|
412
|
+
// Additive: nothing the consumer already had is dropped...
|
|
413
|
+
for (const name of ['steering-injector.sh', 'managed-file-guard.sh', 'uncommitted-check.sh']) {
|
|
414
|
+
assert.ok(raw.includes(name), `upgrade must not drop the pre-wave hook ${name}`);
|
|
415
|
+
}
|
|
416
|
+
// ...and the wave's hooks are added.
|
|
417
|
+
for (const { command } of WAVE2_REGISTRATIONS) {
|
|
418
|
+
assert.ok(raw.includes(path.basename(command)), `upgrade must record ${command}`);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('W4c: a consumer-authored settings.json survives the upgrade untouched', async () => {
|
|
423
|
+
setupPreWaveRepo(consumerDir);
|
|
424
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
425
|
+
mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
426
|
+
writeFileSync(
|
|
427
|
+
settingsPath,
|
|
428
|
+
JSON.stringify(
|
|
429
|
+
{
|
|
430
|
+
env: { MY_KEY: 'value' },
|
|
431
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'mine.sh' }] }] },
|
|
432
|
+
},
|
|
433
|
+
null,
|
|
434
|
+
2,
|
|
435
|
+
),
|
|
436
|
+
'utf8',
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
await runUpdate(consumerDir, {
|
|
440
|
+
fetcher: makeFixtureFetcher(),
|
|
441
|
+
availableTags: AVAILABLE_TAGS,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
445
|
+
assert.deepEqual(settings.env, { MY_KEY: 'value' }, 'consumer env must survive');
|
|
446
|
+
assert.ok(
|
|
447
|
+
settings.hooks.PreToolUse.some((e) =>
|
|
448
|
+
(e.hooks ?? []).some((h) => h.command === 'mine.sh'),
|
|
449
|
+
),
|
|
450
|
+
'a consumer-authored PreToolUse/Bash hook must survive alongside the new managed ones',
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// W5 — check reports drift on a modified wave-2 hook script
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
describe('W5 — check reports drift on a modified hook script', () => {
|
|
460
|
+
let consumerDir;
|
|
461
|
+
|
|
462
|
+
beforeEach(() => {
|
|
463
|
+
consumerDir = makeTmpDir();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
afterEach(() => {
|
|
467
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
async function runCheckExitCode(dir) {
|
|
471
|
+
try {
|
|
472
|
+
await runCheck(dir, { fetcher: makeFixtureFetcher(), availableTags: ['v1.2.0'] });
|
|
473
|
+
return 0;
|
|
474
|
+
} catch (err) {
|
|
475
|
+
return err.exitCode ?? 1;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
it('W5a: check exits 0 on a clean tree with the full hook set installed', async () => {
|
|
480
|
+
setupPreWaveRepo(consumerDir);
|
|
481
|
+
await runUpdate(consumerDir, {
|
|
482
|
+
fetcher: makeFixtureFetcher(),
|
|
483
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
assert.equal(await runCheckExitCode(consumerDir), 0, 'a synced tree must be clean');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('W5b: editing a blocking hook script makes check exit 1', async () => {
|
|
490
|
+
// The drift signal matters more for a blocker than for a reminder: a
|
|
491
|
+
// locally weakened force-push-guard still looks installed and wired.
|
|
492
|
+
setupPreWaveRepo(consumerDir);
|
|
493
|
+
await runUpdate(consumerDir, {
|
|
494
|
+
fetcher: makeFixtureFetcher(),
|
|
495
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const hookPath = path.join(consumerDir, '.claude', 'hooks', 'force-push-guard.sh');
|
|
499
|
+
writeFileSync(hookPath, readFileSync(hookPath, 'utf8') + '\n# locally weakened\n', 'utf8');
|
|
500
|
+
|
|
501
|
+
assert.equal(
|
|
502
|
+
await runCheckExitCode(consumerDir),
|
|
503
|
+
1,
|
|
504
|
+
'check must report drift when a managed hook script is locally modified',
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('W5c: deleting a wave-2 hook script makes check exit 1', async () => {
|
|
509
|
+
setupPreWaveRepo(consumerDir);
|
|
510
|
+
await runUpdate(consumerDir, {
|
|
511
|
+
fetcher: makeFixtureFetcher(),
|
|
512
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
rmSync(path.join(consumerDir, '.claude', 'hooks', 'handoff-state-guard.sh'));
|
|
516
|
+
|
|
517
|
+
assert.equal(
|
|
518
|
+
await runCheckExitCode(consumerDir),
|
|
519
|
+
1,
|
|
520
|
+
'check must report drift when a managed hook script is deleted',
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
* Behavior anchors:
|
|
27
27
|
*
|
|
28
28
|
* R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs) no
|
|
29
|
-
* longer has an entry targeting `.claude/hooks/rules-injector.sh
|
|
30
|
-
*
|
|
29
|
+
* longer has an entry targeting `.claude/hooks/rules-injector.sh`, and no
|
|
30
|
+
* retired command is ever re-registered; the three surviving hooks remain.
|
|
31
31
|
* R2. `runInit` on a fresh consumer never wires a SessionStart registration
|
|
32
32
|
* for rules-injector.sh into `.claude/settings.json` — the three
|
|
33
33
|
* surviving managed hooks are still wired additively.
|
|
@@ -65,7 +65,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
65
65
|
import { runInit } from '../src/commands/init.mjs';
|
|
66
66
|
import { runUpdate } from '../src/commands/update.mjs';
|
|
67
67
|
import { writeLockfile } from '../src/lockfile.mjs';
|
|
68
|
-
import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
|
|
68
|
+
import { DEFAULT_HOOK_SCRIPTS, RETIRED_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
|
|
69
69
|
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
// Paths / fixture helpers (mirrors tests/hooks-cli-install.test.mjs)
|
|
@@ -220,18 +220,21 @@ describe('S1 R1 — DEFAULT_HOOK_SCRIPTS no longer includes rules-injector.sh (r
|
|
|
220
220
|
);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
it('R1b: DEFAULT_HOOK_SCRIPTS
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
223
|
+
it('R1b: DEFAULT_HOOK_SCRIPTS and RETIRED_HOOK_SCRIPTS are disjoint (no retired command is re-registered)', () => {
|
|
224
|
+
// Generalizes what a pinned three-command roster used to assert here. The
|
|
225
|
+
// invariant R1 actually protects is that retirement holds — a command on
|
|
226
|
+
// the append-only retired list must never reappear in the shipped table, or
|
|
227
|
+
// merge-then-prune would add it and immediately remove it on every run.
|
|
228
|
+
// Stating it against the two tables keeps that guard exact while letting a
|
|
229
|
+
// wave register new hooks (260805 registered five) without a hand-edit.
|
|
230
|
+
const registered = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
|
|
231
|
+
const retired = new Set(RETIRED_HOOK_SCRIPTS.map((h) => h.command));
|
|
232
|
+
const reRegistered = registered.filter((c) => retired.has(c)).sort();
|
|
230
233
|
assert.deepEqual(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
`DEFAULT_HOOK_SCRIPTS must register
|
|
234
|
-
`got: ${
|
|
234
|
+
reRegistered,
|
|
235
|
+
[],
|
|
236
|
+
`DEFAULT_HOOK_SCRIPTS must not register any command on RETIRED_HOOK_SCRIPTS; ` +
|
|
237
|
+
`got: ${reRegistered.join(', ')}`,
|
|
235
238
|
);
|
|
236
239
|
});
|
|
237
240
|
|