@dalzoubi/dev-agents-sync 2.0.21 → 2.0.22
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
CHANGED
|
@@ -109,11 +109,12 @@ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions
|
|
|
109
109
|
|
|
110
110
|
### Hook scripts (managed)
|
|
111
111
|
|
|
112
|
-
`init` and `update` install
|
|
112
|
+
`init` and `update` install four hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
|
|
113
113
|
|
|
114
114
|
| Script | Claude Code event | Purpose |
|
|
115
115
|
|---|---|---|
|
|
116
116
|
| `steering-injector.sh` | `SessionStart` | Surfaces steering context at the start of a session. |
|
|
117
|
+
| `rules-injector.sh` | `SessionStart` | Injects the always-on shared rule bodies (`structured-choices`, `tone`) from `.claude/rules/` into every session; fail-safe (skips absent rules, exits 0). |
|
|
117
118
|
| `managed-file-guard.sh` | `PostToolUse` | Guards managed files against unexpected edits. |
|
|
118
119
|
| `uncommitted-check.sh` | `Stop` | Flags uncommitted work when a session ends. |
|
|
119
120
|
|
|
@@ -121,7 +122,7 @@ Hook scripts use the shell-comment marker form `# managed-by: dev-agents-sync vX
|
|
|
121
122
|
|
|
122
123
|
### settings.json auto-wiring
|
|
123
124
|
|
|
124
|
-
By default, `init` and `update` also register the
|
|
125
|
+
By default, `init` and `update` also register the four hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
|
|
125
126
|
|
|
126
127
|
- **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
|
|
127
128
|
- **Idempotent** — running `update` twice produces no duplicate entries.
|
package/package.json
CHANGED
package/src/settings.mjs
CHANGED
|
@@ -263,6 +263,7 @@ export function removeHookSettings(consumerCwd, opts = {}) {
|
|
|
263
263
|
*/
|
|
264
264
|
export const DEFAULT_HOOK_SCRIPTS = [
|
|
265
265
|
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
266
|
+
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/rules-injector.sh' },
|
|
266
267
|
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
267
268
|
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
268
269
|
];
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/rules-injector-registration.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Slice S2 — SessionStart rules-injector.sh hook (registration half).
|
|
5
|
+
* Spec: docs/specs/260708-claude-rules-enforcement/
|
|
6
|
+
*
|
|
7
|
+
* These tests FAIL before the implement agent registers rules-injector.sh as a
|
|
8
|
+
* managed SessionStart hook and PASS after. They are behavior-anchored (§7):
|
|
9
|
+
* each criterion maps to a named test that would fail if the behavior broke.
|
|
10
|
+
*
|
|
11
|
+
* Behavior anchors:
|
|
12
|
+
*
|
|
13
|
+
* R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs) includes
|
|
14
|
+
* a SessionStart entry whose command targets
|
|
15
|
+
* `.claude/hooks/rules-injector.sh`.
|
|
16
|
+
* R2. When the fetched FileMap ships `claude/hooks/rules-injector.sh` (as S3's
|
|
17
|
+
* build.mjs will once the canonical `.agents/hooks/rules-injector.sh`
|
|
18
|
+
* exists), `runInit` wires a HOOK_TAG-tagged SessionStart registration for
|
|
19
|
+
* it into the consumer's `.claude/settings.json`, additively alongside the
|
|
20
|
+
* three pre-existing managed hooks (steering-injector.sh,
|
|
21
|
+
* managed-file-guard.sh, uncommitted-check.sh).
|
|
22
|
+
* R3. `runUpdate` does the same on a pre-initialized consumer repo.
|
|
23
|
+
* R4. Running update twice produces no duplicate rules-injector.sh
|
|
24
|
+
* registration (idempotent, §9).
|
|
25
|
+
*
|
|
26
|
+
* Test runner: `node --test packages/dev-agents-sync/tests/rules-injector-registration.test.mjs`
|
|
27
|
+
* No third-party dependencies. All tests use isolated tmp dirs.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
31
|
+
import assert from 'node:assert/strict';
|
|
32
|
+
import {
|
|
33
|
+
mkdtempSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
readdirSync,
|
|
40
|
+
} from 'node:fs';
|
|
41
|
+
import { tmpdir } from 'node:os';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { fileURLToPath } from 'node:url';
|
|
44
|
+
|
|
45
|
+
import { runInit } from '../src/commands/init.mjs';
|
|
46
|
+
import { runUpdate } from '../src/commands/update.mjs';
|
|
47
|
+
import { writeLockfile } from '../src/lockfile.mjs';
|
|
48
|
+
import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Paths / fixture helpers (mirrors tests/hooks-cli-install.test.mjs)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
56
|
+
|
|
57
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
58
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const full = path.join(currentDir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
collectFiles(baseDir, full, map, prefix);
|
|
63
|
+
} else {
|
|
64
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
65
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds a fixture FileMap that includes the four managed hook scripts —
|
|
72
|
+
* the three pre-existing ones plus rules-injector.sh (as S3's build.mjs will
|
|
73
|
+
* emit once the canonical `.agents/hooks/rules-injector.sh` source exists).
|
|
74
|
+
*/
|
|
75
|
+
function buildFixtureFileMap() {
|
|
76
|
+
const map = {};
|
|
77
|
+
const targetDir = path.join(FIXTURE_DIR, 'claude');
|
|
78
|
+
if (existsSync(targetDir)) {
|
|
79
|
+
collectFiles(targetDir, targetDir, map, 'claude');
|
|
80
|
+
}
|
|
81
|
+
map['claude/hooks/steering-injector.sh'] =
|
|
82
|
+
'#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart hook.\nexit 0\n';
|
|
83
|
+
map['claude/hooks/managed-file-guard.sh'] =
|
|
84
|
+
'#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# PostToolUse hook.\nexit 0\n';
|
|
85
|
+
map['claude/hooks/uncommitted-check.sh'] =
|
|
86
|
+
'#!/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
|
+
return map;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeFixtureFetcher() {
|
|
93
|
+
return async (_repo, _tag, _token) => buildFixtureFileMap();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
|
|
97
|
+
|
|
98
|
+
function makeTmpDir() {
|
|
99
|
+
return mkdtempSync(path.join(tmpdir(), 'das-rules-injector-reg-test-'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setupInitializedRepo(consumerDir, opts = {}) {
|
|
103
|
+
const { targets = ['claude'], resolvedVersion = '1.0.0', range = '^1' } = opts;
|
|
104
|
+
|
|
105
|
+
writeLockfile(consumerDir, {
|
|
106
|
+
source: 'github:dalzoubi/dev-agents',
|
|
107
|
+
range,
|
|
108
|
+
resolvedVersion,
|
|
109
|
+
targets,
|
|
110
|
+
lastUpdated: '2026-06-01T00:00:00Z',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const agentsDir = path.join(consumerDir, '.claude', 'agents');
|
|
114
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
115
|
+
const fixtureContent = readFileSync(
|
|
116
|
+
path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'),
|
|
117
|
+
'utf8',
|
|
118
|
+
);
|
|
119
|
+
writeFileSync(path.join(agentsDir, 'define.md'), fixtureContent, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Collects every hook `command` string registered anywhere in settings.hooks. */
|
|
123
|
+
function allHookCommands(settings) {
|
|
124
|
+
const hooks = settings.hooks ?? {};
|
|
125
|
+
const commands = [];
|
|
126
|
+
for (const entries of Object.values(hooks)) {
|
|
127
|
+
if (!Array.isArray(entries)) continue;
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
130
|
+
for (const h of hookList) {
|
|
131
|
+
if (h.command) commands.push({ command: h.command, tagged: entry[HOOK_TAG] === true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return commands;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// R1 — DEFAULT_HOOK_SCRIPTS registers rules-injector.sh on SessionStart
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
describe('S2 R1 — DEFAULT_HOOK_SCRIPTS includes rules-injector.sh', () => {
|
|
143
|
+
it('R1a: DEFAULT_HOOK_SCRIPTS has an entry whose command is .claude/hooks/rules-injector.sh', () => {
|
|
144
|
+
const entry = DEFAULT_HOOK_SCRIPTS.find(
|
|
145
|
+
(h) => h.command === '.claude/hooks/rules-injector.sh',
|
|
146
|
+
);
|
|
147
|
+
assert.ok(
|
|
148
|
+
entry,
|
|
149
|
+
`DEFAULT_HOOK_SCRIPTS must register .claude/hooks/rules-injector.sh; ` +
|
|
150
|
+
`got commands: ${DEFAULT_HOOK_SCRIPTS.map((h) => h.command).join(', ')}`,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('R1b: the rules-injector.sh registration fires on SessionStart', () => {
|
|
155
|
+
const entry = DEFAULT_HOOK_SCRIPTS.find(
|
|
156
|
+
(h) => h.command === '.claude/hooks/rules-injector.sh',
|
|
157
|
+
);
|
|
158
|
+
assert.ok(entry, 'rules-injector.sh must be registered in DEFAULT_HOOK_SCRIPTS');
|
|
159
|
+
assert.equal(
|
|
160
|
+
entry.event,
|
|
161
|
+
'SessionStart',
|
|
162
|
+
`rules-injector.sh must be registered on the SessionStart event; got: ${entry?.event}`,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('R1c: the three pre-existing managed hooks are still registered (additive, not replaced)', () => {
|
|
167
|
+
const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
|
|
168
|
+
for (const expected of [
|
|
169
|
+
'.claude/hooks/steering-injector.sh',
|
|
170
|
+
'.claude/hooks/managed-file-guard.sh',
|
|
171
|
+
'.claude/hooks/uncommitted-check.sh',
|
|
172
|
+
]) {
|
|
173
|
+
assert.ok(
|
|
174
|
+
commands.includes(expected),
|
|
175
|
+
`registering rules-injector.sh must not drop the existing ${expected} registration`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// R2 — init wires a tagged SessionStart registration for rules-injector.sh
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
|
|
186
|
+
let consumerDir;
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
consumerDir = makeTmpDir();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('R2a: init registers a SessionStart entry for rules-injector.sh', async () => {
|
|
197
|
+
await runInit(consumerDir, {
|
|
198
|
+
targets: ['claude'],
|
|
199
|
+
fetcher: makeFixtureFetcher(),
|
|
200
|
+
availableTags: AVAILABLE_TAGS,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
204
|
+
assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after init');
|
|
205
|
+
|
|
206
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
207
|
+
const sessionStart = settings.hooks?.SessionStart ?? [];
|
|
208
|
+
const found = sessionStart.some(
|
|
209
|
+
(e) =>
|
|
210
|
+
Array.isArray(e.hooks) &&
|
|
211
|
+
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
|
|
212
|
+
);
|
|
213
|
+
assert.ok(
|
|
214
|
+
found,
|
|
215
|
+
'settings.json must contain a SessionStart hook entry for rules-injector.sh after init',
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('R2b: the injected rules-injector.sh entry carries the HOOK_TAG', async () => {
|
|
220
|
+
await runInit(consumerDir, {
|
|
221
|
+
targets: ['claude'],
|
|
222
|
+
fetcher: makeFixtureFetcher(),
|
|
223
|
+
availableTags: AVAILABLE_TAGS,
|
|
224
|
+
});
|
|
225
|
+
|
|
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
|
+
assert.ok(
|
|
236
|
+
taggedEntry,
|
|
237
|
+
`rules-injector.sh SessionStart entry must carry HOOK_TAG ("${HOOK_TAG}") so it is identifiable as CLI-managed`,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('R2c: rules-injector.sh registration coexists with the three pre-existing managed hooks', async () => {
|
|
242
|
+
await runInit(consumerDir, {
|
|
243
|
+
targets: ['claude'],
|
|
244
|
+
fetcher: makeFixtureFetcher(),
|
|
245
|
+
availableTags: AVAILABLE_TAGS,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
249
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
250
|
+
const commands = allHookCommands(settings).map((c) => c.command);
|
|
251
|
+
|
|
252
|
+
for (const expected of [
|
|
253
|
+
'steering-injector.sh',
|
|
254
|
+
'managed-file-guard.sh',
|
|
255
|
+
'uncommitted-check.sh',
|
|
256
|
+
'rules-injector.sh',
|
|
257
|
+
]) {
|
|
258
|
+
assert.ok(
|
|
259
|
+
commands.some((cmd) => cmd.includes(expected)),
|
|
260
|
+
`settings.json must register ${expected} alongside the other managed hooks after init`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// R3 — update wires rules-injector.sh on a pre-initialized consumer repo
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
|
|
271
|
+
let consumerDir;
|
|
272
|
+
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
consumerDir = makeTmpDir();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
afterEach(() => {
|
|
278
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('R3a: update registers a SessionStart entry for rules-injector.sh', async () => {
|
|
282
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
283
|
+
|
|
284
|
+
await runUpdate(consumerDir, {
|
|
285
|
+
fetcher: makeFixtureFetcher(),
|
|
286
|
+
availableTags: AVAILABLE_TAGS,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
290
|
+
assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after update');
|
|
291
|
+
|
|
292
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
293
|
+
const sessionStart = settings.hooks?.SessionStart ?? [];
|
|
294
|
+
const found = sessionStart.some(
|
|
295
|
+
(e) =>
|
|
296
|
+
Array.isArray(e.hooks) &&
|
|
297
|
+
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
|
|
298
|
+
);
|
|
299
|
+
assert.ok(
|
|
300
|
+
found,
|
|
301
|
+
'settings.json must contain a SessionStart hook entry for rules-injector.sh after update',
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('R3b: rules-injector.sh script itself is installed by update', async () => {
|
|
306
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
307
|
+
|
|
308
|
+
await runUpdate(consumerDir, {
|
|
309
|
+
fetcher: makeFixtureFetcher(),
|
|
310
|
+
availableTags: AVAILABLE_TAGS,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
assert.ok(
|
|
314
|
+
existsSync(path.join(consumerDir, '.claude', 'hooks', 'rules-injector.sh')),
|
|
315
|
+
'update must write .claude/hooks/rules-injector.sh',
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// R4 — idempotent: running update twice adds no duplicate registration
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
describe('S2 R4 — repeated update produces no duplicate rules-injector.sh registration', () => {
|
|
325
|
+
let consumerDir;
|
|
326
|
+
|
|
327
|
+
beforeEach(() => {
|
|
328
|
+
consumerDir = makeTmpDir();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('R4: rules-injector.sh command appears at most once after two updates', async () => {
|
|
336
|
+
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
337
|
+
|
|
338
|
+
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
339
|
+
await runUpdate(consumerDir, opts);
|
|
340
|
+
await runUpdate(consumerDir, opts);
|
|
341
|
+
|
|
342
|
+
const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
|
|
343
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
344
|
+
const commands = allHookCommands(settings).map((c) => c.command);
|
|
345
|
+
const occurrences = commands.filter((cmd) => cmd.includes('rules-injector.sh')).length;
|
|
346
|
+
|
|
347
|
+
assert.ok(
|
|
348
|
+
occurrences <= 1,
|
|
349
|
+
`rules-injector.sh hook command must appear at most once after two updates (found: ${occurrences})`,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/rules-injector-runtime.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Slice S2 — SessionStart rules-injector.sh hook (runtime behavior half).
|
|
5
|
+
* Spec: docs/specs/260708-claude-rules-enforcement/
|
|
6
|
+
*
|
|
7
|
+
* Pins the runtime behavior of the shipped `.claude/hooks/rules-injector.sh`
|
|
8
|
+
* script (mirrored from the canonical `.agents/hooks/rules-injector.sh`, which
|
|
9
|
+
* does not exist yet — these tests are the RED anchor for that build step).
|
|
10
|
+
* Follows the steering-injector.sh convention: the hook is invoked with the
|
|
11
|
+
* consumer repo root as its cwd and looks for its inputs relative to that cwd,
|
|
12
|
+
* so we exercise it against an isolated tmp fixture dir rather than depending
|
|
13
|
+
* on this repo's real `.claude/rules/` contents (which may change).
|
|
14
|
+
*
|
|
15
|
+
* Behavior anchors:
|
|
16
|
+
*
|
|
17
|
+
* T1. Executing the script against a fixture dir containing
|
|
18
|
+
* `.claude/rules/structured-choices.md` and `.claude/rules/tone.md`
|
|
19
|
+
* emits both rule BODIES to stdout, with the YAML frontmatter and the
|
|
20
|
+
* `<!-- managed-by: ... -->` marker line stripped.
|
|
21
|
+
* T2. The script exits 0 on that happy path.
|
|
22
|
+
* T3. When one rule file is absent, the script still exits 0 (fail-safe),
|
|
23
|
+
* emits at most a stderr note, and still surfaces the present rule's body.
|
|
24
|
+
* T4. When both rule files are absent (no .claude/rules/ dir at all), the
|
|
25
|
+
* script still exits 0.
|
|
26
|
+
*
|
|
27
|
+
* Test runner: `node --test packages/dev-agents-sync/tests/rules-injector-runtime.test.mjs`
|
|
28
|
+
* No third-party dependencies. All tests use isolated tmp dirs; none of them
|
|
29
|
+
* depend on this repo's actual `.claude/rules/*.md` content.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
33
|
+
import assert from 'node:assert/strict';
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
36
|
+
import { tmpdir } from 'node:os';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Paths
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
// packages/dev-agents-sync/tests -> packages/dev-agents-sync -> packages -> repo root
|
|
46
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
47
|
+
const SCRIPT_PATH = path.join(REPO_ROOT, '.claude', 'hooks', 'rules-injector.sh');
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Fixture content — self-contained, does NOT depend on this repo's actual
|
|
51
|
+
// .claude/rules/*.md bodies so the test stays stable if those rules are edited.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const STRUCTURED_CHOICES_BODY_MARKER = 'FIXTURE_STRUCTURED_CHOICES_BODY_9f3a1';
|
|
55
|
+
const TONE_BODY_MARKER = 'FIXTURE_TONE_BODY_7c22e';
|
|
56
|
+
const FRONTMATTER_NAME_MARKER = 'name: "structured-choices"';
|
|
57
|
+
const MARKDOWN_MANAGED_MARKER = '<!-- managed-by: dev-agents-sync v0.1.0 -->';
|
|
58
|
+
|
|
59
|
+
function structuredChoicesFixture() {
|
|
60
|
+
return [
|
|
61
|
+
'---',
|
|
62
|
+
'name: "structured-choices"',
|
|
63
|
+
'description: "fixture rule for rules-injector runtime tests"',
|
|
64
|
+
'---',
|
|
65
|
+
MARKDOWN_MANAGED_MARKER,
|
|
66
|
+
'# Structured choices',
|
|
67
|
+
'',
|
|
68
|
+
STRUCTURED_CHOICES_BODY_MARKER,
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toneFixture() {
|
|
74
|
+
return [
|
|
75
|
+
'---',
|
|
76
|
+
'name: "tone"',
|
|
77
|
+
'description: "fixture rule for rules-injector runtime tests"',
|
|
78
|
+
'---',
|
|
79
|
+
MARKDOWN_MANAGED_MARKER,
|
|
80
|
+
'# Tone',
|
|
81
|
+
'',
|
|
82
|
+
TONE_BODY_MARKER,
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeTmpDir() {
|
|
88
|
+
return mkdtempSync(path.join(tmpdir(), 'das-rules-injector-runtime-test-'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Runs the shipped rules-injector.sh with `cwd` as its working directory. */
|
|
92
|
+
function runScript(cwd) {
|
|
93
|
+
return spawnSync('sh', [SCRIPT_PATH], { cwd, encoding: 'utf8' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let fixtureDir;
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// T1/T2 — happy path: both rules present
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('S2 T1/T2 — rules-injector.sh emits both rule bodies, exits 0', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
fixtureDir = makeTmpDir();
|
|
105
|
+
const rulesDir = path.join(fixtureDir, '.claude', 'rules');
|
|
106
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
107
|
+
writeFileSync(path.join(rulesDir, 'structured-choices.md'), structuredChoicesFixture(), 'utf8');
|
|
108
|
+
writeFileSync(path.join(rulesDir, 'tone.md'), toneFixture(), 'utf8');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('T2: exits 0 when both rule files are present', () => {
|
|
116
|
+
const result = runScript(fixtureDir);
|
|
117
|
+
assert.equal(
|
|
118
|
+
result.status,
|
|
119
|
+
0,
|
|
120
|
+
`rules-injector.sh must exit 0 on the happy path; got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('T1a: stdout includes the structured-choices.md body', () => {
|
|
125
|
+
const result = runScript(fixtureDir);
|
|
126
|
+
assert.ok(
|
|
127
|
+
(result.stdout ?? '').includes(STRUCTURED_CHOICES_BODY_MARKER),
|
|
128
|
+
`stdout must include the structured-choices.md body; got stdout=${JSON.stringify(result.stdout)}`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('T1b: stdout includes the tone.md body', () => {
|
|
133
|
+
const result = runScript(fixtureDir);
|
|
134
|
+
assert.ok(
|
|
135
|
+
(result.stdout ?? '').includes(TONE_BODY_MARKER),
|
|
136
|
+
`stdout must include the tone.md body; got stdout=${JSON.stringify(result.stdout)}`,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('T1c: stdout strips the YAML frontmatter (no "name:" front-matter key leaks through)', () => {
|
|
141
|
+
const result = runScript(fixtureDir);
|
|
142
|
+
const stdout = result.stdout ?? '';
|
|
143
|
+
// Anchored to the body actually being emitted — otherwise "frontmatter is
|
|
144
|
+
// absent" would pass vacuously against empty stdout (e.g. the script not
|
|
145
|
+
// existing yet) without proving anything about stripping behavior.
|
|
146
|
+
assert.ok(
|
|
147
|
+
stdout.includes(STRUCTURED_CHOICES_BODY_MARKER),
|
|
148
|
+
`precondition failed: stdout must carry the rule body before frontmatter-stripping can be checked; got stdout=${JSON.stringify(stdout)}`,
|
|
149
|
+
);
|
|
150
|
+
assert.ok(
|
|
151
|
+
!stdout.includes(FRONTMATTER_NAME_MARKER),
|
|
152
|
+
`stdout must not include raw frontmatter (${FRONTMATTER_NAME_MARKER}); got stdout=${JSON.stringify(stdout)}`,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('T1d: stdout strips the managed-by marker line', () => {
|
|
157
|
+
const result = runScript(fixtureDir);
|
|
158
|
+
const stdout = result.stdout ?? '';
|
|
159
|
+
// Same anchoring as T1c: require the body to be present so this cannot
|
|
160
|
+
// pass vacuously against empty output.
|
|
161
|
+
assert.ok(
|
|
162
|
+
stdout.includes(TONE_BODY_MARKER),
|
|
163
|
+
`precondition failed: stdout must carry the rule body before marker-stripping can be checked; got stdout=${JSON.stringify(stdout)}`,
|
|
164
|
+
);
|
|
165
|
+
assert.ok(
|
|
166
|
+
!stdout.includes(MARKDOWN_MANAGED_MARKER),
|
|
167
|
+
`stdout must not include the raw managed-by marker line; got stdout=${JSON.stringify(stdout)}`,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// T3 — fail-safe: one rule file absent
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
describe('S2 T3 — rules-injector.sh is fail-safe when one rule file is absent', () => {
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
fixtureDir = makeTmpDir();
|
|
179
|
+
const rulesDir = path.join(fixtureDir, '.claude', 'rules');
|
|
180
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
181
|
+
// Only tone.md exists; structured-choices.md is missing.
|
|
182
|
+
writeFileSync(path.join(rulesDir, 'tone.md'), toneFixture(), 'utf8');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('T3a: still exits 0 when structured-choices.md is absent', () => {
|
|
190
|
+
const result = runScript(fixtureDir);
|
|
191
|
+
assert.equal(
|
|
192
|
+
result.status,
|
|
193
|
+
0,
|
|
194
|
+
`rules-injector.sh must exit 0 even when a rule file is absent (fail-safe); got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('T3b: still surfaces the present rule (tone.md) body on stdout', () => {
|
|
199
|
+
const result = runScript(fixtureDir);
|
|
200
|
+
assert.ok(
|
|
201
|
+
(result.stdout ?? '').includes(TONE_BODY_MARKER),
|
|
202
|
+
`stdout must still include the present tone.md body; got stdout=${JSON.stringify(result.stdout)}`,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('T3c: emits at most a stderr note about the missing rule (does not crash silently on stdout)', () => {
|
|
207
|
+
const result = runScript(fixtureDir);
|
|
208
|
+
const stdout = result.stdout ?? '';
|
|
209
|
+
// Anchored to exit 0 + the present rule's body actually being emitted —
|
|
210
|
+
// otherwise "no fabricated content" would pass vacuously (e.g. against
|
|
211
|
+
// empty stdout from a script that doesn't exist yet or crashed outright)
|
|
212
|
+
// without proving the fail-safe skip behavior.
|
|
213
|
+
assert.equal(
|
|
214
|
+
result.status,
|
|
215
|
+
0,
|
|
216
|
+
`precondition failed: script must exit 0 before the fail-safe skip behavior can be checked; got status=${result.status}`,
|
|
217
|
+
);
|
|
218
|
+
assert.ok(
|
|
219
|
+
stdout.includes(TONE_BODY_MARKER),
|
|
220
|
+
`precondition failed: stdout must carry the present rule's body before checking the missing rule was skipped cleanly; got stdout=${JSON.stringify(stdout)}`,
|
|
221
|
+
);
|
|
222
|
+
// "at most a stderr note" — we don't require stderr, but if the script
|
|
223
|
+
// emits anything about the skip, it must go to stderr, not stdout, and
|
|
224
|
+
// must not fabricate content for the missing rule on stdout.
|
|
225
|
+
assert.ok(
|
|
226
|
+
!stdout.includes('structured-choices'),
|
|
227
|
+
`stdout must not fabricate content for the missing structured-choices.md; got stdout=${JSON.stringify(stdout)}`,
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// T5 — CRLF robustness: frontmatter fences with a trailing CR are still stripped
|
|
234
|
+
// (regression anchor for M1 — the awk fence match must tolerate `---\r`, else
|
|
235
|
+
// on a CRLF checkout the whole YAML frontmatter leaks into injected context).
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe('S2 T5 — rules-injector.sh strips frontmatter on a CRLF checkout', () => {
|
|
239
|
+
beforeEach(() => {
|
|
240
|
+
fixtureDir = makeTmpDir();
|
|
241
|
+
const rulesDir = path.join(fixtureDir, '.claude', 'rules');
|
|
242
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
243
|
+
// Re-encode both fixtures with CRLF line endings, as a CRLF checkout would.
|
|
244
|
+
const toCRLF = (s) => s.replace(/\n/g, '\r\n');
|
|
245
|
+
writeFileSync(path.join(rulesDir, 'structured-choices.md'), toCRLF(structuredChoicesFixture()), 'utf8');
|
|
246
|
+
writeFileSync(path.join(rulesDir, 'tone.md'), toCRLF(toneFixture()), 'utf8');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('T5a: exits 0 and still emits the rule bodies on a CRLF checkout', () => {
|
|
254
|
+
const result = runScript(fixtureDir);
|
|
255
|
+
assert.equal(
|
|
256
|
+
result.status,
|
|
257
|
+
0,
|
|
258
|
+
`rules-injector.sh must exit 0 on a CRLF checkout; got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
|
|
259
|
+
);
|
|
260
|
+
assert.ok(
|
|
261
|
+
(result.stdout ?? '').includes(STRUCTURED_CHOICES_BODY_MARKER),
|
|
262
|
+
`stdout must still include the structured-choices.md body on a CRLF checkout; got stdout=${JSON.stringify(result.stdout)}`,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('T5b: strips the CRLF frontmatter (no "name:" key and no bare "---" fence leaks)', () => {
|
|
267
|
+
const result = runScript(fixtureDir);
|
|
268
|
+
const stdout = result.stdout ?? '';
|
|
269
|
+
// Anchor to the body actually being emitted, so this cannot pass vacuously.
|
|
270
|
+
assert.ok(
|
|
271
|
+
stdout.includes(STRUCTURED_CHOICES_BODY_MARKER),
|
|
272
|
+
`precondition failed: stdout must carry the rule body before frontmatter-stripping can be checked; got stdout=${JSON.stringify(stdout)}`,
|
|
273
|
+
);
|
|
274
|
+
assert.ok(
|
|
275
|
+
!stdout.includes(FRONTMATTER_NAME_MARKER),
|
|
276
|
+
`stdout must not leak raw frontmatter (${FRONTMATTER_NAME_MARKER}) on a CRLF checkout; got stdout=${JSON.stringify(stdout)}`,
|
|
277
|
+
);
|
|
278
|
+
// No line should be a bare YAML fence (with or without a trailing CR).
|
|
279
|
+
assert.ok(
|
|
280
|
+
!/^---\r?$/m.test(stdout),
|
|
281
|
+
`stdout must not leak a bare "---" frontmatter fence on a CRLF checkout; got stdout=${JSON.stringify(stdout)}`,
|
|
282
|
+
);
|
|
283
|
+
// The managed-by marker must also be gone even with the trailing CR.
|
|
284
|
+
assert.ok(
|
|
285
|
+
!stdout.includes(MARKDOWN_MANAGED_MARKER),
|
|
286
|
+
`stdout must not leak the managed-by marker line on a CRLF checkout; got stdout=${JSON.stringify(stdout)}`,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// T4 — fail-safe: no .claude/rules/ directory at all
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
describe('S2 T4 — rules-injector.sh is fail-safe when no rule files exist', () => {
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
fixtureDir = makeTmpDir();
|
|
298
|
+
// Deliberately do NOT create .claude/rules/ at all.
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterEach(() => {
|
|
302
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('T4: exits 0 when .claude/rules/ does not exist', () => {
|
|
306
|
+
const result = runScript(fixtureDir);
|
|
307
|
+
assert.equal(
|
|
308
|
+
result.status,
|
|
309
|
+
0,
|
|
310
|
+
`rules-injector.sh must exit 0 when .claude/rules/ is entirely absent; got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|