@dzhechkov/p-replicator 1.5.13 → 1.5.15
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/.dz-manifest.json +677 -0
- package/CHANGELOG.md +874 -0
- package/KNOWN_LIMITATIONS.md +327 -0
- package/MULTIPLATFORM_ROADMAP.md +239 -0
- package/README/eng/06_troubleshooting.md +1 -1
- package/README/eng/07_changelog.md +57 -0
- package/README/eng/README.md +2 -2
- package/README/ru/06_troubleshooting.md +1 -1
- package/README/ru/07_changelog.md +58 -0
- package/README/ru/README.md +2 -2
- package/README/ru/html/build.js +7 -7
- package/README/ru/html/index.html +31 -12
- package/README.md +59 -10
- package/package.json +10 -3
- package/sbom.json +1683 -0
- package/templates/.claude/skills/explore/SKILL.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +141 -11
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +363 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +297 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +277 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v1_fact.json +21 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +462 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/source_tiers.py +170 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +784 -0
- package/templates/.claude/skills/problem-solver-enhanced/SKILL.md +1 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/SKILL.md +1 -1
- package/tests/e2e/lifecycle.test.js +973 -0
- package/tests/snapshot/baseline.json +125 -0
- package/tests/snapshot/templates.test.js +89 -0
- package/tests/snapshot/update-baseline.js +68 -0
- package/tests/unit/utils.test.js +636 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, describe } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const utils = require('../../src/utils');
|
|
10
|
+
|
|
11
|
+
function tmpDir() {
|
|
12
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-utils-'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function rmRf(dir) {
|
|
16
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// createManifest
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe('createManifest', () => {
|
|
24
|
+
test('returns object with version, installedAt, components, files', () => {
|
|
25
|
+
const m = utils.createManifest('1.0.0', ['a', 'b'], ['x.md', 'y.md']);
|
|
26
|
+
assert.equal(m.version, '1.0.0');
|
|
27
|
+
assert.deepEqual(m.components, ['a', 'b']);
|
|
28
|
+
assert.deepEqual(m.files, ['x.md', 'y.md']);
|
|
29
|
+
assert.ok(m.installedAt, 'installedAt missing');
|
|
30
|
+
assert.ok(!Number.isNaN(Date.parse(m.installedAt)), 'installedAt is not parseable ISO');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// readJSON / writeJSON
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('readJSON/writeJSON', () => {
|
|
39
|
+
test('round-trip preserves data', () => {
|
|
40
|
+
const dir = tmpDir();
|
|
41
|
+
try {
|
|
42
|
+
const p = path.join(dir, 'x.json');
|
|
43
|
+
utils.writeJSON(p, { foo: 'bar', n: 42, list: [1, 2] });
|
|
44
|
+
assert.deepEqual(utils.readJSON(p), { foo: 'bar', n: 42, list: [1, 2] });
|
|
45
|
+
} finally { rmRf(dir); }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('readJSON returns null for nonexistent path', () => {
|
|
49
|
+
assert.equal(utils.readJSON(path.join(os.tmpdir(), 'definitely-missing-12345.json')), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('readJSON returns null for malformed JSON', () => {
|
|
53
|
+
const dir = tmpDir();
|
|
54
|
+
try {
|
|
55
|
+
const p = path.join(dir, 'bad.json');
|
|
56
|
+
fs.writeFileSync(p, '{ not json }');
|
|
57
|
+
assert.equal(utils.readJSON(p), null);
|
|
58
|
+
} finally { rmRf(dir); }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('writeJSON creates parent directories', () => {
|
|
62
|
+
const dir = tmpDir();
|
|
63
|
+
try {
|
|
64
|
+
const p = path.join(dir, 'a', 'b', 'c.json');
|
|
65
|
+
utils.writeJSON(p, { ok: true });
|
|
66
|
+
assert.ok(fs.existsSync(p));
|
|
67
|
+
} finally { rmRf(dir); }
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// fileExists
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('fileExists', () => {
|
|
76
|
+
test('returns true for existing file', () => {
|
|
77
|
+
const dir = tmpDir();
|
|
78
|
+
try {
|
|
79
|
+
const p = path.join(dir, 'x.txt');
|
|
80
|
+
fs.writeFileSync(p, 'hi');
|
|
81
|
+
assert.equal(utils.fileExists(p), true);
|
|
82
|
+
} finally { rmRf(dir); }
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('returns true for existing directory', () => {
|
|
86
|
+
const dir = tmpDir();
|
|
87
|
+
try {
|
|
88
|
+
assert.equal(utils.fileExists(dir), true);
|
|
89
|
+
} finally { rmRf(dir); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns false for nonexistent path', () => {
|
|
93
|
+
assert.equal(utils.fileExists(path.join(os.tmpdir(), 'no-such-thing-987654321')), false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// ensureDir
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('ensureDir', () => {
|
|
102
|
+
test('creates nested directories', () => {
|
|
103
|
+
const dir = tmpDir();
|
|
104
|
+
try {
|
|
105
|
+
const p = path.join(dir, 'a', 'b', 'c');
|
|
106
|
+
utils.ensureDir(p);
|
|
107
|
+
assert.equal(fs.statSync(p).isDirectory(), true);
|
|
108
|
+
} finally { rmRf(dir); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('does not error if directory already exists', () => {
|
|
112
|
+
const dir = tmpDir();
|
|
113
|
+
try {
|
|
114
|
+
utils.ensureDir(dir);
|
|
115
|
+
utils.ensureDir(dir);
|
|
116
|
+
} finally { rmRf(dir); }
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// getRelativePaths
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('getRelativePaths', () => {
|
|
125
|
+
test('returns empty array for empty dir', () => {
|
|
126
|
+
const dir = tmpDir();
|
|
127
|
+
try {
|
|
128
|
+
assert.deepEqual(utils.getRelativePaths(dir), []);
|
|
129
|
+
} finally { rmRf(dir); }
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('returns single file in flat dir', () => {
|
|
133
|
+
const dir = tmpDir();
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'a');
|
|
136
|
+
assert.deepEqual(utils.getRelativePaths(dir), ['a.txt']);
|
|
137
|
+
} finally { rmRf(dir); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('returns nested files using OS path separator', () => {
|
|
141
|
+
const dir = tmpDir();
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(path.join(dir, 'sub'));
|
|
144
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'a');
|
|
145
|
+
fs.writeFileSync(path.join(dir, 'sub', 'b.txt'), 'b');
|
|
146
|
+
const paths = utils.getRelativePaths(dir).sort();
|
|
147
|
+
assert.deepEqual(paths, ['a.txt', path.join('sub', 'b.txt')]);
|
|
148
|
+
} finally { rmRf(dir); }
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('returns empty for nonexistent dir', () => {
|
|
152
|
+
assert.deepEqual(
|
|
153
|
+
utils.getRelativePaths(path.join(os.tmpdir(), 'no-dir-xyz-321')),
|
|
154
|
+
[]
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// copyDirRecursive
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe('copyDirRecursive', () => {
|
|
164
|
+
test('copies single file (creates parent dirs)', () => {
|
|
165
|
+
const dir = tmpDir();
|
|
166
|
+
try {
|
|
167
|
+
const src = path.join(dir, 'a.txt');
|
|
168
|
+
const dst = path.join(dir, 'sub', 'a.txt');
|
|
169
|
+
fs.writeFileSync(src, 'hello');
|
|
170
|
+
utils.copyDirRecursive(src, dst);
|
|
171
|
+
assert.equal(fs.readFileSync(dst, 'utf8'), 'hello');
|
|
172
|
+
} finally { rmRf(dir); }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('copies directory tree preserving content and structure', () => {
|
|
176
|
+
const dir = tmpDir();
|
|
177
|
+
try {
|
|
178
|
+
const src = path.join(dir, 'src');
|
|
179
|
+
const dst = path.join(dir, 'dst');
|
|
180
|
+
fs.mkdirSync(path.join(src, 'sub'), { recursive: true });
|
|
181
|
+
fs.writeFileSync(path.join(src, 'a.txt'), 'A');
|
|
182
|
+
fs.writeFileSync(path.join(src, 'sub', 'b.txt'), 'B');
|
|
183
|
+
|
|
184
|
+
utils.copyDirRecursive(src, dst);
|
|
185
|
+
|
|
186
|
+
assert.equal(fs.readFileSync(path.join(dst, 'a.txt'), 'utf8'), 'A');
|
|
187
|
+
assert.equal(fs.readFileSync(path.join(dst, 'sub', 'b.txt'), 'utf8'), 'B');
|
|
188
|
+
} finally { rmRf(dir); }
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// copyDirFiltered
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe('copyDirFiltered', () => {
|
|
197
|
+
test('only copies top-level entries matching filter', () => {
|
|
198
|
+
const dir = tmpDir();
|
|
199
|
+
try {
|
|
200
|
+
const src = path.join(dir, 'src');
|
|
201
|
+
const dst = path.join(dir, 'dst');
|
|
202
|
+
fs.mkdirSync(src, { recursive: true });
|
|
203
|
+
fs.writeFileSync(path.join(src, 'replicate.md'), 'A');
|
|
204
|
+
fs.writeFileSync(path.join(src, 'other.md'), 'B');
|
|
205
|
+
fs.writeFileSync(path.join(src, 'harvest.md'), 'C');
|
|
206
|
+
|
|
207
|
+
const filter = (name) => name.startsWith('replicate') || name.startsWith('harvest');
|
|
208
|
+
utils.copyDirFiltered(src, dst, filter);
|
|
209
|
+
|
|
210
|
+
assert.equal(fs.readFileSync(path.join(dst, 'replicate.md'), 'utf8'), 'A');
|
|
211
|
+
assert.equal(fs.readFileSync(path.join(dst, 'harvest.md'), 'utf8'), 'C');
|
|
212
|
+
assert.equal(fs.existsSync(path.join(dst, 'other.md')), false);
|
|
213
|
+
} finally { rmRf(dir); }
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// diffFiles
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe('diffFiles', () => {
|
|
222
|
+
function setup(dir, srcMap, dstMap) {
|
|
223
|
+
const srcDir = path.join(dir, 'src');
|
|
224
|
+
const dstDir = path.join(dir, 'dst');
|
|
225
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
226
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
227
|
+
for (const [rel, content] of Object.entries(srcMap)) {
|
|
228
|
+
const p = path.join(srcDir, rel);
|
|
229
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
230
|
+
fs.writeFileSync(p, content);
|
|
231
|
+
}
|
|
232
|
+
for (const [rel, content] of Object.entries(dstMap)) {
|
|
233
|
+
const p = path.join(dstDir, rel);
|
|
234
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
235
|
+
fs.writeFileSync(p, content);
|
|
236
|
+
}
|
|
237
|
+
return { srcDir, dstDir };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test('identical dirs: all files unchanged', () => {
|
|
241
|
+
const dir = tmpDir();
|
|
242
|
+
try {
|
|
243
|
+
const { srcDir, dstDir } = setup(dir, { 'a.md': 'X' }, { 'a.md': 'X' });
|
|
244
|
+
const r = utils.diffFiles(srcDir, dstDir);
|
|
245
|
+
assert.deepEqual(r.unchanged, ['a.md']);
|
|
246
|
+
assert.deepEqual(r.added, []);
|
|
247
|
+
assert.deepEqual(r.modified, []);
|
|
248
|
+
assert.deepEqual(r.missing, []);
|
|
249
|
+
} finally { rmRf(dir); }
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('new file in src: classified as added', () => {
|
|
253
|
+
const dir = tmpDir();
|
|
254
|
+
try {
|
|
255
|
+
const { srcDir, dstDir } = setup(dir, { 'a.md': 'X', 'b.md': 'Y' }, { 'a.md': 'X' });
|
|
256
|
+
const r = utils.diffFiles(srcDir, dstDir);
|
|
257
|
+
assert.deepEqual(r.added, ['b.md']);
|
|
258
|
+
assert.deepEqual(r.unchanged, ['a.md']);
|
|
259
|
+
} finally { rmRf(dir); }
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('modified content: classified as modified', () => {
|
|
263
|
+
const dir = tmpDir();
|
|
264
|
+
try {
|
|
265
|
+
const { srcDir, dstDir } = setup(dir, { 'a.md': 'NEW' }, { 'a.md': 'OLD' });
|
|
266
|
+
const r = utils.diffFiles(srcDir, dstDir);
|
|
267
|
+
assert.deepEqual(r.modified, ['a.md']);
|
|
268
|
+
assert.deepEqual(r.unchanged, []);
|
|
269
|
+
} finally { rmRf(dir); }
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('file only in dest: classified as missing', () => {
|
|
273
|
+
const dir = tmpDir();
|
|
274
|
+
try {
|
|
275
|
+
const { srcDir, dstDir } = setup(dir, {}, { 'a.md': 'X' });
|
|
276
|
+
const r = utils.diffFiles(srcDir, dstDir);
|
|
277
|
+
assert.deepEqual(r.missing, ['a.md']);
|
|
278
|
+
} finally { rmRf(dir); }
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('both empty: all categories empty', () => {
|
|
282
|
+
const dir = tmpDir();
|
|
283
|
+
try {
|
|
284
|
+
const { srcDir, dstDir } = setup(dir, {}, {});
|
|
285
|
+
const r = utils.diffFiles(srcDir, dstDir);
|
|
286
|
+
assert.deepEqual(r.added, []);
|
|
287
|
+
assert.deepEqual(r.modified, []);
|
|
288
|
+
assert.deepEqual(r.unchanged, []);
|
|
289
|
+
assert.deepEqual(r.missing, []);
|
|
290
|
+
} finally { rmRf(dir); }
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// COMPONENTS / MANIFEST_FILE
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe('COMPONENTS', () => {
|
|
299
|
+
test('every pre-shipped component has src, label, group fields', () => {
|
|
300
|
+
for (const [key, comp] of Object.entries(utils.COMPONENTS)) {
|
|
301
|
+
if (comp.kind !== 'pre-shipped') continue;
|
|
302
|
+
assert.ok(comp.src, `${key} missing src`);
|
|
303
|
+
assert.ok(comp.label, `${key} missing label`);
|
|
304
|
+
assert.ok(comp.group, `${key} missing group`);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('all pre-shipped component sources point under .claude/', () => {
|
|
309
|
+
for (const [key, comp] of Object.entries(utils.COMPONENTS)) {
|
|
310
|
+
if (comp.kind !== 'pre-shipped') continue;
|
|
311
|
+
assert.match(comp.src, /^\.claude\//, `${key}.src does not start with .claude/`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('every project-generated component has label, group, items', () => {
|
|
316
|
+
for (const [key, comp] of Object.entries(utils.COMPONENTS)) {
|
|
317
|
+
if (comp.kind !== 'project-generated') continue;
|
|
318
|
+
assert.ok(comp.label, `${key} missing label`);
|
|
319
|
+
assert.ok(comp.group, `${key} missing group`);
|
|
320
|
+
assert.ok(comp.items, `${key} missing items`);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('MANIFEST_FILE constant', () => {
|
|
326
|
+
test('is .p-replicator.json', () => {
|
|
327
|
+
assert.equal(utils.MANIFEST_FILE, '.p-replicator.json');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// SSOT: COMPONENTS.items (single source of truth for component names)
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
describe('COMPONENTS.items SSOT', () => {
|
|
336
|
+
test('every component declares an items map', () => {
|
|
337
|
+
for (const [key, comp] of Object.entries(utils.COMPONENTS)) {
|
|
338
|
+
assert.ok(comp.items, `${key} missing items`);
|
|
339
|
+
assert.equal(typeof comp.items, 'object', `${key}.items is not an object`);
|
|
340
|
+
assert.ok(Object.keys(comp.items).length > 0, `${key}.items is empty`);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('item counts: 10 skills, 11 commands, 4 agents, 5 rules, 1 settings', () => {
|
|
345
|
+
assert.equal(Object.keys(utils.COMPONENTS.skills.items).length, 10, 'skills count');
|
|
346
|
+
assert.equal(Object.keys(utils.COMPONENTS.commands.items).length, 11, 'commands count (post v1.4: + 9 generic commands)');
|
|
347
|
+
assert.equal(Object.keys(utils.COMPONENTS.agents.items).length, 4, 'agents count');
|
|
348
|
+
assert.equal(Object.keys(utils.COMPONENTS.rules.items).length, 5, 'rules count (post v1.4: + 3 generic rules)');
|
|
349
|
+
assert.ok(utils.COMPONENTS.settings, 'settings component group added in v1.4');
|
|
350
|
+
assert.equal(Object.keys(utils.COMPONENTS.settings.items).length, 1, 'settings.json count');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('v1.4 generic commands present in items', () => {
|
|
354
|
+
const generic = ['start', 'plan', 'feature', 'go', 'run', 'next', 'docs', 'deploy', 'myinsights'];
|
|
355
|
+
for (const cmd of generic) {
|
|
356
|
+
assert.ok(cmd in utils.COMPONENTS.commands.items,
|
|
357
|
+
`${cmd} should be in COMPONENTS.commands.items (added in v1.4)`);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('v1.4 generic rules present in items', () => {
|
|
362
|
+
const generic = ['git-workflow', 'insights-capture', 'feature-lifecycle'];
|
|
363
|
+
for (const rule of generic) {
|
|
364
|
+
assert.ok(rule in utils.COMPONENTS.rules.items,
|
|
365
|
+
`${rule} should be in COMPONENTS.rules.items (added in v1.4)`);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('every item has a non-empty string description', () => {
|
|
370
|
+
for (const [groupKey, comp] of Object.entries(utils.COMPONENTS)) {
|
|
371
|
+
for (const [name, desc] of Object.entries(comp.items)) {
|
|
372
|
+
assert.equal(typeof desc, 'string', `${groupKey}.${name} desc not string`);
|
|
373
|
+
assert.ok(desc.length > 0, `${groupKey}.${name} desc empty`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('expected canonical names present in items', () => {
|
|
379
|
+
const expectedSkills = [
|
|
380
|
+
'explore', 'sparc-prd-mini', 'goap-research-ed25519',
|
|
381
|
+
'problem-solver-enhanced', 'requirements-validator',
|
|
382
|
+
'brutal-honesty-review', 'cc-toolkit-generator-enhanced',
|
|
383
|
+
'reverse-engineering-unicorn', 'pipeline-forge', 'knowledge-extractor',
|
|
384
|
+
];
|
|
385
|
+
for (const skill of expectedSkills) {
|
|
386
|
+
assert.ok(skill in utils.COMPONENTS.skills.items, `missing skill: ${skill}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
assert.ok('replicate' in utils.COMPONENTS.commands.items);
|
|
390
|
+
assert.ok('harvest' in utils.COMPONENTS.commands.items);
|
|
391
|
+
|
|
392
|
+
const expectedAgents = [
|
|
393
|
+
'replicate-coordinator', 'product-discoverer',
|
|
394
|
+
'doc-validator', 'harvest-coordinator',
|
|
395
|
+
];
|
|
396
|
+
for (const a of expectedAgents) {
|
|
397
|
+
assert.ok(a in utils.COMPONENTS.agents.items, `missing agent: ${a}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
assert.ok('replicate-pipeline' in utils.COMPONENTS.rules.items);
|
|
401
|
+
assert.ok('skill-interface-protocol' in utils.COMPONENTS.rules.items);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// v1.4.1: kind discrimination + project-generated SSOT + cross-platform hooks
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
describe('COMPONENTS.kind SSOT (v1.4.1)', () => {
|
|
410
|
+
test('every component has kind: pre-shipped | project-generated', () => {
|
|
411
|
+
for (const [key, comp] of Object.entries(utils.COMPONENTS)) {
|
|
412
|
+
assert.ok(
|
|
413
|
+
['pre-shipped', 'project-generated'].includes(comp.kind),
|
|
414
|
+
`${key}.kind must be pre-shipped or project-generated, got ${JSON.stringify(comp.kind)}`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('pre-shipped groups include hooks (post v1.4.1)', () => {
|
|
420
|
+
const preShipped = Object.entries(utils.COMPONENTS)
|
|
421
|
+
.filter(([, c]) => c.kind === 'pre-shipped')
|
|
422
|
+
.map(([k]) => k)
|
|
423
|
+
.sort();
|
|
424
|
+
assert.ok(preShipped.includes('hooks'), 'hooks group added in v1.4.1');
|
|
425
|
+
assert.ok(preShipped.length >= 6, `expected ≥ 6 pre-shipped groups, got ${preShipped.length}`);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('project-generated groups added (post v1.4.1)', () => {
|
|
429
|
+
const projectGen = Object.entries(utils.COMPONENTS)
|
|
430
|
+
.filter(([, c]) => c.kind === 'project-generated')
|
|
431
|
+
.map(([k]) => k);
|
|
432
|
+
assert.ok(projectGen.length >= 3,
|
|
433
|
+
`expected ≥ 3 project-generated groups, got ${projectGen.length}: ${projectGen.join(',')}`);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('hooks group items include 4 v1.4.1 + 2 v1.5.0 scripts', () => {
|
|
437
|
+
assert.ok(utils.COMPONENTS.hooks, 'hooks component should exist');
|
|
438
|
+
const hookKeys = Object.keys(utils.COMPONENTS.hooks.items);
|
|
439
|
+
// v1.4.1 baseline: 4 cross-platform scripts
|
|
440
|
+
for (const k of ['autocommit-insights', 'autocommit-plans', 'autocommit-roadmap', 'session-insights']) {
|
|
441
|
+
assert.ok(hookKeys.includes(k), `${k} should be present (v1.4.1 baseline)`);
|
|
442
|
+
}
|
|
443
|
+
// v1.5.0 added: statusline + state-update
|
|
444
|
+
assert.ok(hookKeys.includes('statusline'), 'statusline added in v1.5.0');
|
|
445
|
+
assert.ok(hookKeys.includes('state-update'), 'state-update added in v1.5.0');
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('utils.getItemRelativePath (v1.4.1 helper)', () => {
|
|
450
|
+
test('handles isFile components (settings.json)', () => {
|
|
451
|
+
const comp = utils.COMPONENTS.settings;
|
|
452
|
+
assert.equal(utils.getItemRelativePath(comp, 'settings.json'), '.claude/settings.json');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('skills: <src>/<name>/SKILL.md', () => {
|
|
456
|
+
const result = utils.getItemRelativePath(utils.COMPONENTS.skills, 'explore');
|
|
457
|
+
assert.match(result.replace(/\\/g, '/'), /\.claude\/skills\/explore\/SKILL\.md/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('commands: <src>/<name>.md', () => {
|
|
461
|
+
const result = utils.getItemRelativePath(utils.COMPONENTS.commands, 'run');
|
|
462
|
+
assert.match(result.replace(/\\/g, '/'), /\.claude\/commands\/run\.md/);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('hooks: <src>/<name>.cjs', () => {
|
|
466
|
+
const result = utils.getItemRelativePath(utils.COMPONENTS.hooks, 'session-insights');
|
|
467
|
+
assert.match(result.replace(/\\/g, '/'), /\.claude\/hooks\/session-insights\.cjs/);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('project-generated: items keys are full paths', () => {
|
|
471
|
+
const [, comp] = Object.entries(utils.COMPONENTS)
|
|
472
|
+
.find(([, c]) => c.kind === 'project-generated') || [];
|
|
473
|
+
assert.ok(comp, 'should have at least one project-generated component');
|
|
474
|
+
const sampleKey = Object.keys(comp.items)[0];
|
|
475
|
+
const result = utils.getItemRelativePath(comp, sampleKey);
|
|
476
|
+
assert.equal(result, sampleKey,
|
|
477
|
+
`project-generated items use full paths as keys; got '${result}' for '${sampleKey}'`);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// v1.4.2: settings.json merge (preserve user customizations)
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
describe('utils.mergeSettingsJson (v1.4.2)', () => {
|
|
486
|
+
test('null existing returns template untouched', () => {
|
|
487
|
+
const tpl = { hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'x' }] }] } };
|
|
488
|
+
assert.deepEqual(utils.mergeSettingsJson(null, tpl), tpl);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('null template returns existing untouched', () => {
|
|
492
|
+
const ex = { hooks: { Stop: [] } };
|
|
493
|
+
assert.deepEqual(utils.mergeSettingsJson(ex, null), ex);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('preserves user-only event types (e.g., PreToolUse)', () => {
|
|
497
|
+
const ex = { hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'audit' }] }] } };
|
|
498
|
+
const tpl = { hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'auto' }] }] } };
|
|
499
|
+
const merged = utils.mergeSettingsJson(ex, tpl);
|
|
500
|
+
assert.ok(merged.hooks.PreToolUse, 'user PreToolUse preserved');
|
|
501
|
+
assert.ok(merged.hooks.Stop, 'template Stop added');
|
|
502
|
+
assert.equal(merged.hooks.PreToolUse[0].hooks[0].command, 'audit');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('merges hooks within same event + matcher, de-duped by command string', () => {
|
|
506
|
+
const ex = {
|
|
507
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'user-custom' }] }] },
|
|
508
|
+
};
|
|
509
|
+
const tpl = {
|
|
510
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'template-default' }] }] },
|
|
511
|
+
};
|
|
512
|
+
const merged = utils.mergeSettingsJson(ex, tpl);
|
|
513
|
+
const cmds = merged.hooks.Stop[0].hooks.map((h) => h.command);
|
|
514
|
+
assert.ok(cmds.includes('user-custom'), 'user hook preserved');
|
|
515
|
+
assert.ok(cmds.includes('template-default'), 'template hook added');
|
|
516
|
+
assert.equal(cmds.length, 2, 'no duplication');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('does NOT duplicate template hook if user already has identical command', () => {
|
|
520
|
+
const ex = { hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'same' }] }] } };
|
|
521
|
+
const tpl = { hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'same' }] }] } };
|
|
522
|
+
const merged = utils.mergeSettingsJson(ex, tpl);
|
|
523
|
+
assert.equal(merged.hooks.Stop[0].hooks.length, 1, 'no duplication of identical commands');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('different matchers are kept separately within same event', () => {
|
|
527
|
+
const ex = { hooks: { Stop: [{ matcher: 'Bash', hooks: [{ command: 'audit' }] }] } };
|
|
528
|
+
const tpl = { hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'auto' }] }] } };
|
|
529
|
+
const merged = utils.mergeSettingsJson(ex, tpl);
|
|
530
|
+
assert.equal(merged.hooks.Stop.length, 2, 'two distinct matchers');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('preserves user top-level fields not in template', () => {
|
|
534
|
+
const ex = { customField: 'preserved', hooks: {} };
|
|
535
|
+
const tpl = { hooks: { Stop: [] } };
|
|
536
|
+
const merged = utils.mergeSettingsJson(ex, tpl);
|
|
537
|
+
assert.equal(merged.customField, 'preserved');
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// v1.4.3: removeOrphanHooks (orphan detection on upgrade)
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
describe('utils.removeOrphanHooks (v1.4.3)', () => {
|
|
546
|
+
test('returns existing unchanged when oldTemplate is null (first upgrade)', () => {
|
|
547
|
+
const ex = { hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'A' }] }] } };
|
|
548
|
+
const result = utils.removeOrphanHooks(ex, null, { hooks: {} });
|
|
549
|
+
assert.deepEqual(result, ex);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('removes hook that was in oldTemplate but not in newTemplate (orphan)', () => {
|
|
553
|
+
const existing = {
|
|
554
|
+
hooks: {
|
|
555
|
+
Stop: [{
|
|
556
|
+
matcher: '*',
|
|
557
|
+
hooks: [
|
|
558
|
+
{ command: 'OLD_DEFAULT' },
|
|
559
|
+
{ command: 'USER_CUSTOM' },
|
|
560
|
+
],
|
|
561
|
+
}],
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
const oldTpl = {
|
|
565
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'OLD_DEFAULT' }] }] },
|
|
566
|
+
};
|
|
567
|
+
const newTpl = {
|
|
568
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'NEW_DEFAULT' }] }] },
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const cleaned = utils.removeOrphanHooks(existing, oldTpl, newTpl);
|
|
572
|
+
const cmds = cleaned.hooks.Stop[0].hooks.map((h) => h.command);
|
|
573
|
+
assert.ok(!cmds.includes('OLD_DEFAULT'),
|
|
574
|
+
'OLD_DEFAULT (was default, no longer shipped) should be removed');
|
|
575
|
+
assert.ok(cmds.includes('USER_CUSTOM'),
|
|
576
|
+
'USER_CUSTOM (never in oldTemplate) preserved');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('keeps hook that is still in newTemplate (not an orphan)', () => {
|
|
580
|
+
const existing = {
|
|
581
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'STILL_THERE' }] }] },
|
|
582
|
+
};
|
|
583
|
+
const oldTpl = {
|
|
584
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'STILL_THERE' }] }] },
|
|
585
|
+
};
|
|
586
|
+
const newTpl = {
|
|
587
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'STILL_THERE' }] }] },
|
|
588
|
+
};
|
|
589
|
+
const cleaned = utils.removeOrphanHooks(existing, oldTpl, newTpl);
|
|
590
|
+
const cmds = cleaned.hooks.Stop[0].hooks.map((h) => h.command);
|
|
591
|
+
assert.ok(cmds.includes('STILL_THERE'));
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('user-modified default (different command-string) preserved as user-added', () => {
|
|
595
|
+
const existing = {
|
|
596
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'modified-by-user' }] }] },
|
|
597
|
+
};
|
|
598
|
+
const oldTpl = {
|
|
599
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'original-default' }] }] },
|
|
600
|
+
};
|
|
601
|
+
const newTpl = {
|
|
602
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ command: 'original-default' }] }] },
|
|
603
|
+
};
|
|
604
|
+
const cleaned = utils.removeOrphanHooks(existing, oldTpl, newTpl);
|
|
605
|
+
const cmds = cleaned.hooks.Stop[0].hooks.map((h) => h.command);
|
|
606
|
+
// 'modified-by-user' was not in oldTpl, so not orphan
|
|
607
|
+
assert.ok(cmds.includes('modified-by-user'),
|
|
608
|
+
'user-modified default treated as user-added; preserved');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('does not crash on missing hooks property', () => {
|
|
612
|
+
assert.doesNotThrow(() =>
|
|
613
|
+
utils.removeOrphanHooks({}, { hooks: { Stop: [] } }, { hooks: {} })
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// v1.5.0: statusline metadata in COMPONENTS.hooks.items
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
describe('COMPONENTS.hooks (v1.5.0)', () => {
|
|
623
|
+
test('hooks group includes statusline + state-update scripts', () => {
|
|
624
|
+
const hookKeys = Object.keys(utils.COMPONENTS.hooks.items).sort();
|
|
625
|
+
assert.ok(hookKeys.includes('statusline'),
|
|
626
|
+
'statusline.cjs added in v1.5.0');
|
|
627
|
+
assert.ok(hookKeys.includes('state-update'),
|
|
628
|
+
'state-update.cjs added in v1.5.0');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('hooks group has at least 6 items (4 v1.4.1 + 2 v1.5.0)', () => {
|
|
632
|
+
const hookCount = Object.keys(utils.COMPONENTS.hooks.items).length;
|
|
633
|
+
assert.ok(hookCount >= 6,
|
|
634
|
+
`expected >= 6 hook scripts (post v1.5.0), got ${hookCount}`);
|
|
635
|
+
});
|
|
636
|
+
});
|