@unrdf/kgc-runtime 26.4.2

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.
Files changed (70) hide show
  1. package/IMPLEMENTATION_SUMMARY.json +150 -0
  2. package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
  3. package/README.md +98 -0
  4. package/TRANSACTION_IMPLEMENTATION.json +119 -0
  5. package/capability-map.md +93 -0
  6. package/docs/api-stability.md +269 -0
  7. package/docs/extensions/plugin-development.md +382 -0
  8. package/package.json +40 -0
  9. package/plugins/registry.json +35 -0
  10. package/src/admission-gate.mjs +414 -0
  11. package/src/api-version.mjs +373 -0
  12. package/src/atomic-admission.mjs +310 -0
  13. package/src/bounds.mjs +289 -0
  14. package/src/bulkhead-manager.mjs +280 -0
  15. package/src/capsule.mjs +524 -0
  16. package/src/crdt.mjs +361 -0
  17. package/src/enhanced-bounds.mjs +614 -0
  18. package/src/executor.mjs +73 -0
  19. package/src/freeze-restore.mjs +521 -0
  20. package/src/index.mjs +62 -0
  21. package/src/materialized-views.mjs +371 -0
  22. package/src/merge.mjs +472 -0
  23. package/src/plugin-isolation.mjs +392 -0
  24. package/src/plugin-manager.mjs +441 -0
  25. package/src/projections-api.mjs +336 -0
  26. package/src/projections-cli.mjs +238 -0
  27. package/src/projections-docs.mjs +300 -0
  28. package/src/projections-ide.mjs +278 -0
  29. package/src/receipt.mjs +340 -0
  30. package/src/rollback.mjs +258 -0
  31. package/src/saga-orchestrator.mjs +355 -0
  32. package/src/schemas.mjs +1330 -0
  33. package/src/storage-optimization.mjs +359 -0
  34. package/src/tool-registry.mjs +272 -0
  35. package/src/transaction.mjs +466 -0
  36. package/src/validators.mjs +485 -0
  37. package/src/work-item.mjs +449 -0
  38. package/templates/plugin-template/README.md +58 -0
  39. package/templates/plugin-template/index.mjs +162 -0
  40. package/templates/plugin-template/plugin.json +19 -0
  41. package/test/admission-gate.test.mjs +583 -0
  42. package/test/api-version.test.mjs +74 -0
  43. package/test/atomic-admission.test.mjs +155 -0
  44. package/test/bounds.test.mjs +341 -0
  45. package/test/bulkhead-manager.test.mjs +236 -0
  46. package/test/capsule.test.mjs +625 -0
  47. package/test/crdt.test.mjs +215 -0
  48. package/test/enhanced-bounds.test.mjs +487 -0
  49. package/test/freeze-restore.test.mjs +472 -0
  50. package/test/materialized-views.test.mjs +243 -0
  51. package/test/merge.test.mjs +665 -0
  52. package/test/plugin-isolation.test.mjs +109 -0
  53. package/test/plugin-manager.test.mjs +208 -0
  54. package/test/projections-api.test.mjs +293 -0
  55. package/test/projections-cli.test.mjs +204 -0
  56. package/test/projections-docs.test.mjs +173 -0
  57. package/test/projections-ide.test.mjs +230 -0
  58. package/test/receipt.test.mjs +295 -0
  59. package/test/rollback.test.mjs +132 -0
  60. package/test/saga-orchestrator.test.mjs +279 -0
  61. package/test/schemas.test.mjs +716 -0
  62. package/test/storage-optimization.test.mjs +503 -0
  63. package/test/tool-registry.test.mjs +341 -0
  64. package/test/transaction.test.mjs +189 -0
  65. package/test/validators.test.mjs +463 -0
  66. package/test/work-item.test.mjs +548 -0
  67. package/test/work-item.test.mjs.bak +548 -0
  68. package/var/kgc/test-atomic-log.json +519 -0
  69. package/var/kgc/test-cascading-log.json +145 -0
  70. package/vitest.config.mjs +18 -0
@@ -0,0 +1,665 @@
1
+ /**
2
+ * @fileoverview Tests for multi-agent capsule merge and conflict resolution
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import {
7
+ shardMerge,
8
+ mergeCapsules,
9
+ ConflictDetector,
10
+ ConflictResolver,
11
+ } from '../src/merge.mjs';
12
+
13
+ // ============================================================================
14
+ // Test Data Fixtures
15
+ // ============================================================================
16
+
17
+ const createCapsule = (id, o_hash, file_edits) => ({
18
+ id,
19
+ o_hash,
20
+ file_edits,
21
+ metadata: { created_at: new Date().toISOString() },
22
+ });
23
+
24
+ const createFileEdit = (file_path, line_start, line_end, content, operation = 'replace') => ({
25
+ file_path,
26
+ line_start,
27
+ line_end,
28
+ content,
29
+ operation,
30
+ });
31
+
32
+ const defaultTotalOrder = {
33
+ rules: [],
34
+ default_rule: {
35
+ strategy: 'earlier_wins',
36
+ },
37
+ };
38
+
39
+ // ============================================================================
40
+ // ConflictDetector Tests
41
+ // ============================================================================
42
+
43
+ describe('ConflictDetector', () => {
44
+ describe('rangesOverlap', () => {
45
+ it('should detect overlapping ranges', () => {
46
+ expect(ConflictDetector.rangesOverlap(1, 5, 3, 7)).toBe(true);
47
+ expect(ConflictDetector.rangesOverlap(1, 5, 5, 10)).toBe(true);
48
+ expect(ConflictDetector.rangesOverlap(3, 7, 1, 5)).toBe(true);
49
+ });
50
+
51
+ it('should detect non-overlapping ranges', () => {
52
+ expect(ConflictDetector.rangesOverlap(1, 5, 6, 10)).toBe(false);
53
+ expect(ConflictDetector.rangesOverlap(6, 10, 1, 5)).toBe(false);
54
+ });
55
+
56
+ it('should handle edge cases', () => {
57
+ expect(ConflictDetector.rangesOverlap(1, 5, 1, 5)).toBe(true);
58
+ expect(ConflictDetector.rangesOverlap(1, 1, 1, 1)).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe('detectConflicts', () => {
63
+ it('should detect no conflicts when ranges do not overlap', () => {
64
+ const capsules = [
65
+ createCapsule('c1', 'hash1', [
66
+ createFileEdit('file.js', 1, 5, 'content1'),
67
+ ]),
68
+ createCapsule('c2', 'hash2', [
69
+ createFileEdit('file.js', 10, 15, 'content2'),
70
+ ]),
71
+ ];
72
+
73
+ const conflicts = ConflictDetector.detectConflicts(capsules);
74
+ expect(conflicts).toHaveLength(0);
75
+ });
76
+
77
+ it('should detect conflict when two capsules edit same file region', () => {
78
+ const capsules = [
79
+ createCapsule('c1', 'hash1', [
80
+ createFileEdit('file.js', 1, 10, 'content1'),
81
+ ]),
82
+ createCapsule('c2', 'hash2', [
83
+ createFileEdit('file.js', 5, 15, 'content2'),
84
+ ]),
85
+ ];
86
+
87
+ const conflicts = ConflictDetector.detectConflicts(capsules);
88
+ expect(conflicts).toHaveLength(1);
89
+ expect(conflicts[0].file_path).toBe('file.js');
90
+ expect(conflicts[0].overlapping_edits).toHaveLength(2);
91
+ });
92
+
93
+ it('should detect conflicts in different files independently', () => {
94
+ const capsules = [
95
+ createCapsule('c1', 'hash1', [
96
+ createFileEdit('file1.js', 1, 10, 'content1'),
97
+ createFileEdit('file2.js', 1, 10, 'content2'),
98
+ ]),
99
+ createCapsule('c2', 'hash2', [
100
+ createFileEdit('file1.js', 5, 15, 'content3'),
101
+ createFileEdit('file3.js', 1, 10, 'content4'),
102
+ ]),
103
+ ];
104
+
105
+ const conflicts = ConflictDetector.detectConflicts(capsules);
106
+ expect(conflicts).toHaveLength(1);
107
+ expect(conflicts[0].file_path).toBe('file1.js');
108
+ });
109
+ });
110
+
111
+ describe('createReceipt', () => {
112
+ it('should create valid conflict receipt', () => {
113
+ const conflict = {
114
+ file_path: 'test.js',
115
+ line_start: 1,
116
+ line_end: 10,
117
+ overlapping_edits: [
118
+ { capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
119
+ { capsule_id: 'c2', edit: createFileEdit('test.js', 3, 10, 'content2') },
120
+ ],
121
+ };
122
+
123
+ const receipt = ConflictDetector.createReceipt(
124
+ conflict,
125
+ 'earlier_wins',
126
+ 'c1',
127
+ ['c2']
128
+ );
129
+
130
+ expect(receipt).toMatchObject({
131
+ file_path: 'test.js',
132
+ capsules_involved: ['c1', 'c2'],
133
+ resolution_rule: 'earlier_wins',
134
+ winner: 'c1',
135
+ denied: ['c2'],
136
+ });
137
+ expect(receipt.conflict_id).toBeDefined();
138
+ expect(receipt.timestamp).toBeDefined();
139
+ expect(receipt.line_ranges).toHaveLength(2);
140
+ });
141
+ });
142
+ });
143
+
144
+ // ============================================================================
145
+ // ConflictResolver Tests
146
+ // ============================================================================
147
+
148
+ describe('ConflictResolver', () => {
149
+ const createConflict = (edits) => ({
150
+ file_path: 'test.js',
151
+ line_start: 1,
152
+ line_end: 10,
153
+ overlapping_edits: edits,
154
+ });
155
+
156
+ describe('earlierWins', () => {
157
+ it('should select capsule with earliest o_hash', () => {
158
+ const capsules = [
159
+ createCapsule('c1', 'hash_c', []),
160
+ createCapsule('c2', 'hash_a', []),
161
+ createCapsule('c3', 'hash_b', []),
162
+ ];
163
+
164
+ const conflict = createConflict([
165
+ { capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
166
+ { capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
167
+ { capsule_id: 'c3', edit: createFileEdit('test.js', 5, 10, 'content3') },
168
+ ]);
169
+
170
+ const result = ConflictResolver.earlierWins(conflict, capsules);
171
+ expect(result.winner).toBe('c2'); // hash_a is earliest
172
+ expect(result.denied).toContain('c1');
173
+ expect(result.denied).toContain('c3');
174
+ });
175
+ });
176
+
177
+ describe('laterWins', () => {
178
+ it('should select capsule with latest o_hash', () => {
179
+ const capsules = [
180
+ createCapsule('c1', 'hash_a', []),
181
+ createCapsule('c2', 'hash_c', []),
182
+ createCapsule('c3', 'hash_b', []),
183
+ ];
184
+
185
+ const conflict = createConflict([
186
+ { capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
187
+ { capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
188
+ ]);
189
+
190
+ const result = ConflictResolver.laterWins(conflict, capsules);
191
+ expect(result.winner).toBe('c2'); // hash_c is latest
192
+ expect(result.denied).toContain('c1');
193
+ });
194
+ });
195
+
196
+ describe('lexicographic', () => {
197
+ it('should select capsule with lexicographically first id', () => {
198
+ const conflict = createConflict([
199
+ { capsule_id: 'capsule_3', edit: createFileEdit('test.js', 1, 5, 'content1') },
200
+ { capsule_id: 'capsule_1', edit: createFileEdit('test.js', 3, 7, 'content2') },
201
+ { capsule_id: 'capsule_2', edit: createFileEdit('test.js', 5, 10, 'content3') },
202
+ ]);
203
+
204
+ const result = ConflictResolver.lexicographic(conflict);
205
+ expect(result.winner).toBe('capsule_1');
206
+ expect(result.denied).toEqual(['capsule_2', 'capsule_3']);
207
+ });
208
+ });
209
+
210
+ describe('resolveConflict', () => {
211
+ it('should apply specified strategy', () => {
212
+ const capsules = [
213
+ createCapsule('c1', 'hash_b', []),
214
+ createCapsule('c2', 'hash_a', []),
215
+ ];
216
+
217
+ const conflict = createConflict([
218
+ { capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
219
+ { capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
220
+ ]);
221
+
222
+ const rule = { strategy: 'earlier_wins' };
223
+ const result = ConflictResolver.resolveConflict(conflict, rule, capsules);
224
+
225
+ expect(result.winner).toBe('c2');
226
+ expect(result.rule).toBe('earlier_wins');
227
+ });
228
+
229
+ it('should handle merge_all strategy', () => {
230
+ const capsules = [
231
+ createCapsule('c1', 'hash_a', []),
232
+ createCapsule('c2', 'hash_b', []),
233
+ ];
234
+
235
+ const conflict = createConflict([
236
+ { capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
237
+ { capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
238
+ ]);
239
+
240
+ const rule = { strategy: 'merge_all' };
241
+ const result = ConflictResolver.resolveConflict(conflict, rule, capsules);
242
+
243
+ expect(result.winner).toBeNull();
244
+ expect(result.denied).toEqual([]);
245
+ });
246
+ });
247
+ });
248
+
249
+ // ============================================================================
250
+ // shardMerge Tests
251
+ // ============================================================================
252
+
253
+ describe('shardMerge', () => {
254
+ it('should merge capsules with no conflicts', () => {
255
+ const capsules = [
256
+ createCapsule('c1', 'hash1', [
257
+ createFileEdit('file1.js', 1, 5, 'content1'),
258
+ ]),
259
+ createCapsule('c2', 'hash2', [
260
+ createFileEdit('file2.js', 1, 5, 'content2'),
261
+ ]),
262
+ ];
263
+
264
+ const result = shardMerge(capsules, defaultTotalOrder);
265
+
266
+ expect(result.merged_state).toBeDefined();
267
+ expect(Object.keys(result.merged_state)).toHaveLength(2);
268
+ expect(result.conflict_receipts).toHaveLength(0);
269
+ });
270
+
271
+ it('should handle two-capsule merge with conflict', () => {
272
+ const capsules = [
273
+ createCapsule('c1', 'hash_b', [
274
+ createFileEdit('file.js', 1, 10, 'content1'),
275
+ ]),
276
+ createCapsule('c2', 'hash_a', [
277
+ createFileEdit('file.js', 5, 15, 'content2'),
278
+ ]),
279
+ ];
280
+
281
+ const result = shardMerge(capsules, defaultTotalOrder);
282
+
283
+ expect(result.conflict_receipts).toHaveLength(1);
284
+ expect(result.conflict_receipts[0].winner).toBe('c2'); // hash_a wins
285
+ expect(result.conflict_receipts[0].denied).toContain('c1');
286
+ expect(Object.keys(result.merged_state)).toHaveLength(1);
287
+ expect(result.merged_state.c2).toBeDefined();
288
+ });
289
+
290
+ it('should handle three-capsule merge (multi-way conflict)', () => {
291
+ const capsules = [
292
+ createCapsule('c1', 'hash_c', [
293
+ createFileEdit('file.js', 1, 10, 'content1'),
294
+ ]),
295
+ createCapsule('c2', 'hash_a', [
296
+ createFileEdit('file.js', 5, 15, 'content2'),
297
+ ]),
298
+ createCapsule('c3', 'hash_b', [
299
+ createFileEdit('file.js', 7, 20, 'content3'),
300
+ ]),
301
+ ];
302
+
303
+ const result = shardMerge(capsules, defaultTotalOrder);
304
+
305
+ expect(result.conflict_receipts).toHaveLength(1);
306
+ expect(result.conflict_receipts[0].capsules_involved).toHaveLength(3);
307
+ expect(result.conflict_receipts[0].winner).toBe('c2'); // hash_a wins
308
+ expect(result.conflict_receipts[0].denied).toContain('c1');
309
+ expect(result.conflict_receipts[0].denied).toContain('c3');
310
+ });
311
+
312
+ it('should produce deterministic ordering', () => {
313
+ const capsules = [
314
+ createCapsule('c1', 'hash_b', [
315
+ createFileEdit('file.js', 1, 10, 'content1'),
316
+ ]),
317
+ createCapsule('c2', 'hash_a', [
318
+ createFileEdit('file.js', 5, 15, 'content2'),
319
+ ]),
320
+ ];
321
+
322
+ // Run multiple times
323
+ const result1 = shardMerge(capsules, defaultTotalOrder);
324
+ const result2 = shardMerge(capsules, defaultTotalOrder);
325
+ const result3 = shardMerge(capsules, defaultTotalOrder);
326
+
327
+ expect(result1.conflict_receipts[0].winner).toBe(result2.conflict_receipts[0].winner);
328
+ expect(result2.conflict_receipts[0].winner).toBe(result3.conflict_receipts[0].winner);
329
+ expect(result1.conflict_receipts[0].winner).toBe('c2');
330
+ });
331
+ });
332
+
333
+ // ============================================================================
334
+ // mergeCapsules Tests
335
+ // ============================================================================
336
+
337
+ describe('mergeCapsules', () => {
338
+ it('should merge capsules with no conflicts', () => {
339
+ const capsules = [
340
+ createCapsule('c1', 'hash1', [
341
+ createFileEdit('file1.js', 1, 5, 'content1'),
342
+ ]),
343
+ createCapsule('c2', 'hash2', [
344
+ createFileEdit('file2.js', 1, 5, 'content2'),
345
+ ]),
346
+ ];
347
+
348
+ const result = mergeCapsules(capsules, defaultTotalOrder);
349
+
350
+ expect(result.admitted).toEqual(['c1', 'c2']);
351
+ expect(result.denied).toEqual([]);
352
+ expect(result.conflict_receipts).toHaveLength(0);
353
+ expect(result.merged_state).toBeDefined();
354
+ });
355
+
356
+ it('should handle two-capsule conflict', () => {
357
+ const capsules = [
358
+ createCapsule('c1', 'hash_b', [
359
+ createFileEdit('file.js', 1, 10, 'content1'),
360
+ ]),
361
+ createCapsule('c2', 'hash_a', [
362
+ createFileEdit('file.js', 5, 15, 'content2'),
363
+ ]),
364
+ ];
365
+
366
+ const result = mergeCapsules(capsules, defaultTotalOrder);
367
+
368
+ expect(result.admitted).toEqual(['c2']);
369
+ expect(result.denied).toEqual(['c1']);
370
+ expect(result.conflict_receipts).toHaveLength(1);
371
+ expect(result.conflict_receipts[0].winner).toBe('c2');
372
+ });
373
+
374
+ it('should handle three-capsule multi-way conflict', () => {
375
+ const capsules = [
376
+ createCapsule('c1', 'hash_c', [
377
+ createFileEdit('file.js', 1, 10, 'content1'),
378
+ ]),
379
+ createCapsule('c2', 'hash_a', [
380
+ createFileEdit('file.js', 5, 15, 'content2'),
381
+ ]),
382
+ createCapsule('c3', 'hash_b', [
383
+ createFileEdit('file.js', 7, 20, 'content3'),
384
+ ]),
385
+ ];
386
+
387
+ const result = mergeCapsules(capsules, defaultTotalOrder);
388
+
389
+ expect(result.admitted).toEqual(['c2']);
390
+ expect(result.denied).toContain('c1');
391
+ expect(result.denied).toContain('c3');
392
+ expect(result.conflict_receipts).toHaveLength(1);
393
+ });
394
+
395
+ it('should generate conflict receipts for each conflict', () => {
396
+ const capsules = [
397
+ createCapsule('c1', 'hash_b', [
398
+ createFileEdit('file1.js', 1, 10, 'content1'),
399
+ createFileEdit('file2.js', 1, 10, 'content2'),
400
+ ]),
401
+ createCapsule('c2', 'hash_a', [
402
+ createFileEdit('file1.js', 5, 15, 'content3'),
403
+ createFileEdit('file2.js', 5, 15, 'content4'),
404
+ ]),
405
+ ];
406
+
407
+ const result = mergeCapsules(capsules, defaultTotalOrder);
408
+
409
+ expect(result.conflict_receipts).toHaveLength(2);
410
+ expect(result.conflict_receipts[0].file_path).not.toBe(
411
+ result.conflict_receipts[1].file_path
412
+ );
413
+ expect(result.conflict_receipts.every((r) => r.winner === 'c2')).toBe(true);
414
+ });
415
+
416
+ it('should enforce deterministic ordering across multiple runs', () => {
417
+ const capsules = [
418
+ createCapsule('capsule_3', 'hash_c', [
419
+ createFileEdit('file.js', 1, 10, 'content1'),
420
+ ]),
421
+ createCapsule('capsule_1', 'hash_a', [
422
+ createFileEdit('file.js', 5, 15, 'content2'),
423
+ ]),
424
+ createCapsule('capsule_2', 'hash_b', [
425
+ createFileEdit('file.js', 7, 20, 'content3'),
426
+ ]),
427
+ ];
428
+
429
+ const result1 = mergeCapsules(capsules, defaultTotalOrder);
430
+ const result2 = mergeCapsules(capsules, defaultTotalOrder);
431
+ const result3 = mergeCapsules(capsules, defaultTotalOrder);
432
+
433
+ expect(result1.admitted).toEqual(result2.admitted);
434
+ expect(result2.admitted).toEqual(result3.admitted);
435
+ expect(result1.denied.sort()).toEqual(result2.denied.sort());
436
+ });
437
+
438
+ it('should use lexicographic strategy when specified', () => {
439
+ const totalOrder = {
440
+ rules: [],
441
+ default_rule: { strategy: 'lexicographic' },
442
+ };
443
+
444
+ const capsules = [
445
+ createCapsule('capsule_z', 'hash_a', [
446
+ createFileEdit('file.js', 1, 10, 'content1'),
447
+ ]),
448
+ createCapsule('capsule_a', 'hash_z', [
449
+ createFileEdit('file.js', 5, 15, 'content2'),
450
+ ]),
451
+ ];
452
+
453
+ const result = mergeCapsules(capsules, totalOrder);
454
+
455
+ expect(result.admitted).toEqual(['capsule_a']);
456
+ expect(result.denied).toEqual(['capsule_z']);
457
+ expect(result.conflict_receipts[0].resolution_rule).toBe('lexicographic');
458
+ });
459
+
460
+ it('should handle validation errors gracefully', () => {
461
+ const invalidCapsules = [
462
+ { id: 'c1' }, // Missing required fields
463
+ ];
464
+
465
+ expect(() => {
466
+ mergeCapsules(invalidCapsules, defaultTotalOrder);
467
+ }).toThrow();
468
+ });
469
+
470
+ it('should preserve metadata in merged state', () => {
471
+ const capsules = [
472
+ createCapsule('c1', 'hash1', [
473
+ createFileEdit('file.js', 1, 5, 'content1'),
474
+ ]),
475
+ ];
476
+ capsules[0].metadata = { custom_field: 'value' };
477
+
478
+ const result = mergeCapsules(capsules, defaultTotalOrder);
479
+
480
+ expect(result.merged_state.c1.metadata).toEqual({ custom_field: 'value' });
481
+ });
482
+ });
483
+
484
+ // ============================================================================
485
+ // Integration Tests
486
+ // ============================================================================
487
+
488
+ describe('Integration Tests', () => {
489
+ it('should handle complex multi-file, multi-capsule scenario', () => {
490
+ const capsules = [
491
+ createCapsule('agent1', 'hash_1', [
492
+ createFileEdit('src/main.js', 1, 10, 'agent1 main changes'),
493
+ createFileEdit('src/utils.js', 20, 30, 'agent1 utils changes'),
494
+ ]),
495
+ createCapsule('agent2', 'hash_2', [
496
+ createFileEdit('src/main.js', 5, 15, 'agent2 main changes'),
497
+ createFileEdit('src/config.js', 1, 5, 'agent2 config changes'),
498
+ ]),
499
+ createCapsule('agent3', 'hash_3', [
500
+ createFileEdit('src/utils.js', 25, 35, 'agent3 utils changes'),
501
+ createFileEdit('src/tests.js', 1, 20, 'agent3 test changes'),
502
+ ]),
503
+ ];
504
+
505
+ const result = mergeCapsules(capsules, defaultTotalOrder);
506
+
507
+ expect(result.admitted.length + result.denied.length).toBe(3);
508
+ expect(result.conflict_receipts.length).toBeGreaterThan(0);
509
+ expect(result.merged_state).toBeDefined();
510
+ });
511
+
512
+ it('should handle empty capsule list', () => {
513
+ const result = mergeCapsules([], defaultTotalOrder);
514
+
515
+ expect(result.admitted).toEqual([]);
516
+ expect(result.denied).toEqual([]);
517
+ expect(result.conflict_receipts).toEqual([]);
518
+ });
519
+
520
+ it('should handle single capsule (no conflicts possible)', () => {
521
+ const capsules = [
522
+ createCapsule('c1', 'hash1', [
523
+ createFileEdit('file.js', 1, 10, 'content'),
524
+ ]),
525
+ ];
526
+
527
+ const result = mergeCapsules(capsules, defaultTotalOrder);
528
+
529
+ expect(result.admitted).toEqual(['c1']);
530
+ expect(result.denied).toEqual([]);
531
+ expect(result.conflict_receipts).toEqual([]);
532
+ });
533
+
534
+ describe('merge_all strategy', () => {
535
+ it('should admit all capsules in 2-way conflict with merge_all', () => {
536
+ const totalOrder = {
537
+ rules: [],
538
+ default_rule: { strategy: 'merge_all' },
539
+ };
540
+
541
+ const capsules = [
542
+ createCapsule('c1', 'hash_b', [
543
+ createFileEdit('file.js', 1, 10, 'content1'),
544
+ ]),
545
+ createCapsule('c2', 'hash_a', [
546
+ createFileEdit('file.js', 5, 15, 'content2'),
547
+ ]),
548
+ ];
549
+
550
+ const result = mergeCapsules(capsules, totalOrder);
551
+
552
+ // Both capsules should be admitted
553
+ expect(result.admitted).toEqual(['c1', 'c2']);
554
+ expect(result.denied).toEqual([]);
555
+
556
+ // Should still emit conflict receipt as notice
557
+ expect(result.conflict_receipts).toHaveLength(1);
558
+ expect(result.conflict_receipts[0].resolution_rule).toBe('merge_all');
559
+
560
+ // Merged state should contain both capsules
561
+ expect(Object.keys(result.merged_state)).toHaveLength(2);
562
+ expect(result.merged_state.c1).toBeDefined();
563
+ expect(result.merged_state.c2).toBeDefined();
564
+ });
565
+
566
+ it('should admit all capsules in 3-way conflict with merge_all', () => {
567
+ const totalOrder = {
568
+ rules: [],
569
+ default_rule: { strategy: 'merge_all' },
570
+ };
571
+
572
+ const capsules = [
573
+ createCapsule('c1', 'hash_c', [
574
+ createFileEdit('file.js', 1, 10, 'content1'),
575
+ ]),
576
+ createCapsule('c2', 'hash_a', [
577
+ createFileEdit('file.js', 5, 15, 'content2'),
578
+ ]),
579
+ createCapsule('c3', 'hash_b', [
580
+ createFileEdit('file.js', 7, 20, 'content3'),
581
+ ]),
582
+ ];
583
+
584
+ const result = mergeCapsules(capsules, totalOrder);
585
+
586
+ // All three capsules should be admitted
587
+ expect(result.admitted).toEqual(['c1', 'c2', 'c3']);
588
+ expect(result.denied).toEqual([]);
589
+
590
+ // Should emit conflict receipt
591
+ expect(result.conflict_receipts).toHaveLength(1);
592
+ expect(result.conflict_receipts[0].capsules_involved).toHaveLength(3);
593
+
594
+ // All capsules in merged state
595
+ expect(Object.keys(result.merged_state)).toHaveLength(3);
596
+ });
597
+
598
+ it('should emit multiple conflict receipts for multiple conflicts with merge_all', () => {
599
+ const totalOrder = {
600
+ rules: [],
601
+ default_rule: { strategy: 'merge_all' },
602
+ };
603
+
604
+ const capsules = [
605
+ createCapsule('c1', 'hash_a', [
606
+ createFileEdit('file1.js', 1, 10, 'content1'),
607
+ createFileEdit('file2.js', 1, 10, 'content2'),
608
+ ]),
609
+ createCapsule('c2', 'hash_b', [
610
+ createFileEdit('file1.js', 5, 15, 'content3'),
611
+ createFileEdit('file2.js', 5, 15, 'content4'),
612
+ ]),
613
+ ];
614
+
615
+ const result = mergeCapsules(capsules, totalOrder);
616
+
617
+ // Both admitted
618
+ expect(result.admitted).toEqual(['c1', 'c2']);
619
+ expect(result.denied).toEqual([]);
620
+
621
+ // Two conflict receipts (one per file)
622
+ expect(result.conflict_receipts).toHaveLength(2);
623
+
624
+ // All in merged state
625
+ expect(Object.keys(result.merged_state)).toHaveLength(2);
626
+ });
627
+
628
+ it('should handle merge_all with partial conflicts (some files overlap, some don\'t)', () => {
629
+ const totalOrder = {
630
+ rules: [],
631
+ default_rule: { strategy: 'merge_all' },
632
+ };
633
+
634
+ const capsules = [
635
+ createCapsule('c1', 'hash_a', [
636
+ createFileEdit('file1.js', 1, 10, 'content1'),
637
+ createFileEdit('file2.js', 1, 10, 'content2'),
638
+ ]),
639
+ createCapsule('c2', 'hash_b', [
640
+ createFileEdit('file1.js', 5, 15, 'content3'), // Conflicts with c1
641
+ createFileEdit('file3.js', 1, 10, 'content4'), // No conflict
642
+ ]),
643
+ createCapsule('c3', 'hash_c', [
644
+ createFileEdit('file4.js', 1, 10, 'content5'), // No conflict
645
+ ]),
646
+ ];
647
+
648
+ const result = mergeCapsules(capsules, totalOrder);
649
+
650
+ // All admitted
651
+ expect(result.admitted).toEqual(['c1', 'c2', 'c3']);
652
+ expect(result.denied).toEqual([]);
653
+
654
+ // Only one conflict (file1.js between c1 and c2)
655
+ expect(result.conflict_receipts).toHaveLength(1);
656
+ expect(result.conflict_receipts[0].file_path).toBe('file1.js');
657
+
658
+ // All in merged state
659
+ expect(Object.keys(result.merged_state)).toHaveLength(3);
660
+ expect(result.merged_state.c1.file_edits).toHaveLength(2);
661
+ expect(result.merged_state.c2.file_edits).toHaveLength(2);
662
+ expect(result.merged_state.c3.file_edits).toHaveLength(1);
663
+ });
664
+ });
665
+ });