tcsetup 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/yaml-merge.js +710 -0
package/package.json
CHANGED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML Merge Module
|
|
3
|
+
* Handles intelligent merging of YAML configuration files with deduplication
|
|
4
|
+
* and proper handling of nested objects and arrays.
|
|
5
|
+
*
|
|
6
|
+
* Zero runtime dependencies - uses only Node.js built-ins (no external YAML libraries)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Helper Functions - Deep Equality and Comparison
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively checks if two values are deeply equal
|
|
15
|
+
* @param {*} a - First value to compare
|
|
16
|
+
* @param {*} b - Second value to compare
|
|
17
|
+
* @returns {boolean} True if values are deeply equal
|
|
18
|
+
*/
|
|
19
|
+
export function deepEqual(a, b) {
|
|
20
|
+
// Primitive types
|
|
21
|
+
if (a === b) return true;
|
|
22
|
+
if (a == null || b == null) return a === b;
|
|
23
|
+
if (typeof a !== typeof b) return false;
|
|
24
|
+
|
|
25
|
+
// Arrays
|
|
26
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
27
|
+
if (a.length !== b.length) return false;
|
|
28
|
+
return a.every((item, i) => deepEqual(item, b[i]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Objects
|
|
32
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
33
|
+
const keysA = Object.keys(a);
|
|
34
|
+
const keysB = Object.keys(b);
|
|
35
|
+
if (keysA.length !== keysB.length) return false;
|
|
36
|
+
return keysA.every((key) => deepEqual(a[key], b[key]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Array Deduplication
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Deduplicates an array by removing duplicate items found in the existing array
|
|
48
|
+
* Uses deep equality checking
|
|
49
|
+
* @param {Array} existing - Current array values
|
|
50
|
+
* @param {Array} update - New items to merge
|
|
51
|
+
* @returns {Array} Merged array with duplicates removed
|
|
52
|
+
*/
|
|
53
|
+
export function deduplicateArrays(existing, update) {
|
|
54
|
+
if (!Array.isArray(existing)) existing = [];
|
|
55
|
+
if (!Array.isArray(update)) update = [];
|
|
56
|
+
|
|
57
|
+
const result = [...existing];
|
|
58
|
+
const dedup = [];
|
|
59
|
+
|
|
60
|
+
for (const item of update) {
|
|
61
|
+
const isDuplicate = result.some((existingItem) =>
|
|
62
|
+
deepEqual(item, existingItem)
|
|
63
|
+
);
|
|
64
|
+
if (!isDuplicate) {
|
|
65
|
+
result.push(item);
|
|
66
|
+
dedup.push(item);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { result, deduped: dedup.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Object Merging
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Recursively merges two objects
|
|
79
|
+
* New keys are added, existing keys are preserved, nested objects are merged recursively
|
|
80
|
+
* @param {object} existing - Current object
|
|
81
|
+
* @param {object} update - Object with new/updated values
|
|
82
|
+
* @returns {object} Merged object
|
|
83
|
+
*/
|
|
84
|
+
export function mergeObjects(existing, update) {
|
|
85
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
|
86
|
+
return update || {};
|
|
87
|
+
}
|
|
88
|
+
if (!update || typeof update !== 'object' || Array.isArray(update)) {
|
|
89
|
+
return existing;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = { ...existing };
|
|
93
|
+
|
|
94
|
+
for (const [key, value] of Object.entries(update)) {
|
|
95
|
+
if (key in result) {
|
|
96
|
+
// Key exists - recursively merge if both are objects
|
|
97
|
+
if (
|
|
98
|
+
typeof result[key] === 'object' &&
|
|
99
|
+
!Array.isArray(result[key]) &&
|
|
100
|
+
typeof value === 'object' &&
|
|
101
|
+
!Array.isArray(value)
|
|
102
|
+
) {
|
|
103
|
+
result[key] = mergeObjects(result[key], value);
|
|
104
|
+
} else if (Array.isArray(result[key]) && Array.isArray(value)) {
|
|
105
|
+
// Both are arrays - deduplicate
|
|
106
|
+
const { result: merged } = deduplicateArrays(result[key], value);
|
|
107
|
+
result[key] = merged;
|
|
108
|
+
}
|
|
109
|
+
// Otherwise keep existing value (don't overwrite)
|
|
110
|
+
} else {
|
|
111
|
+
// New key - add it
|
|
112
|
+
result[key] = value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// YAML Parsing and Serialization
|
|
121
|
+
// ============================================================================
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Simple YAML parser for basic structures (objects, arrays, strings, numbers, booleans, null)
|
|
125
|
+
* Handles indentation-based structure and preserves comments
|
|
126
|
+
* @param {string} content - YAML string content
|
|
127
|
+
* @returns {object} Parsed YAML with { data, comments, raw }
|
|
128
|
+
*/
|
|
129
|
+
export function parseYAML(content) {
|
|
130
|
+
if (!content || typeof content !== 'string') {
|
|
131
|
+
return { data: {}, comments: {}, raw: content || '' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const lines = content.split('\n');
|
|
136
|
+
return { data: parseYAMLLines(lines, 0).data, comments: {}, raw: content };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { data: {}, comments: {}, raw: content, parseError: error.message };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Recursively parse YAML lines starting at a given index and indentation level
|
|
144
|
+
*/
|
|
145
|
+
function parseYAMLLines(lines, startIdx = 0, expectedIndent = 0, parentIsArray = false) {
|
|
146
|
+
const result = {};
|
|
147
|
+
const items = [];
|
|
148
|
+
let i = startIdx;
|
|
149
|
+
let isArray = parentIsArray;
|
|
150
|
+
let currentArrayItem = null;
|
|
151
|
+
|
|
152
|
+
while (i < lines.length) {
|
|
153
|
+
const line = lines[i];
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
|
|
156
|
+
// Skip empty lines and comments
|
|
157
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
158
|
+
i++;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const indent = line.length - line.trimStart().length;
|
|
163
|
+
|
|
164
|
+
// If indent is less than expected, we're done with this level
|
|
165
|
+
if (indent < expectedIndent) {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// If indent is greater than expected, skip until we find matching indent
|
|
170
|
+
if (indent > expectedIndent) {
|
|
171
|
+
i++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle array items
|
|
176
|
+
if (trimmed.startsWith('- ')) {
|
|
177
|
+
isArray = true;
|
|
178
|
+
const itemValue = trimmed.substring(2).trim();
|
|
179
|
+
|
|
180
|
+
// Check if next line is more indented (nested object/array)
|
|
181
|
+
if (i + 1 < lines.length) {
|
|
182
|
+
const nextLine = lines[i + 1];
|
|
183
|
+
const nextTrimmed = nextLine.trim();
|
|
184
|
+
const nextIndent = nextLine.length - nextLine.trimStart().length;
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
nextIndent > indent &&
|
|
188
|
+
!nextTrimmed.startsWith('#') &&
|
|
189
|
+
nextTrimmed &&
|
|
190
|
+
nextTrimmed.includes(':') &&
|
|
191
|
+
!nextTrimmed.startsWith('-')
|
|
192
|
+
) {
|
|
193
|
+
// Nested object in array
|
|
194
|
+
const nested = parseYAMLLines(lines, i + 1, nextIndent, false);
|
|
195
|
+
// If there was a value after the dash, add it as a property
|
|
196
|
+
if (itemValue) {
|
|
197
|
+
const [k, ...vParts] = itemValue.split(':');
|
|
198
|
+
nested.data[k.trim()] = parseYAMLValue(vParts.join(':').trim());
|
|
199
|
+
}
|
|
200
|
+
items.push(nested.data);
|
|
201
|
+
currentArrayItem = nested.data;
|
|
202
|
+
i = nested.i;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Simple array item
|
|
208
|
+
if (itemValue) {
|
|
209
|
+
items.push(parseYAMLValue(itemValue));
|
|
210
|
+
currentArrayItem = null;
|
|
211
|
+
}
|
|
212
|
+
i++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Handle key: value pairs
|
|
217
|
+
if (trimmed.includes(':')) {
|
|
218
|
+
const colonIdx = trimmed.indexOf(':');
|
|
219
|
+
const key = trimmed.substring(0, colonIdx).trim();
|
|
220
|
+
const valueStr = trimmed.substring(colonIdx + 1).trim();
|
|
221
|
+
|
|
222
|
+
// If we're in an array item (next indent is array), add to current item
|
|
223
|
+
if (currentArrayItem && typeof currentArrayItem === 'object' && !Array.isArray(currentArrayItem)) {
|
|
224
|
+
if (!valueStr) {
|
|
225
|
+
// Nested content follows
|
|
226
|
+
if (i + 1 < lines.length) {
|
|
227
|
+
const nextLine = lines[i + 1];
|
|
228
|
+
const nextTrimmed = nextLine.trim();
|
|
229
|
+
const nextIndent = nextLine.length - nextLine.trimStart().length;
|
|
230
|
+
|
|
231
|
+
if (nextIndent > indent && !nextTrimmed.startsWith('#') && nextTrimmed) {
|
|
232
|
+
const nested = parseYAMLLines(lines, i + 1, nextIndent, nextTrimmed.startsWith('-'));
|
|
233
|
+
currentArrayItem[key] = nested.data;
|
|
234
|
+
i = nested.i;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
currentArrayItem[key] = null;
|
|
239
|
+
} else {
|
|
240
|
+
currentArrayItem[key] = parseYAMLValue(valueStr);
|
|
241
|
+
}
|
|
242
|
+
i++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Normal object key
|
|
247
|
+
if (!valueStr) {
|
|
248
|
+
// Nested content follows
|
|
249
|
+
if (i + 1 < lines.length) {
|
|
250
|
+
const nextLine = lines[i + 1];
|
|
251
|
+
const nextTrimmed = nextLine.trim();
|
|
252
|
+
const nextIndent = nextLine.length - nextLine.trimStart().length;
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
nextIndent > indent &&
|
|
256
|
+
!nextTrimmed.startsWith('#') &&
|
|
257
|
+
nextTrimmed
|
|
258
|
+
) {
|
|
259
|
+
// This is nested
|
|
260
|
+
const nested = parseYAMLLines(lines, i + 1, nextIndent, nextTrimmed.startsWith('-'));
|
|
261
|
+
result[key] = nested.data;
|
|
262
|
+
i = nested.i;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
result[key] = null;
|
|
267
|
+
} else {
|
|
268
|
+
result[key] = parseYAMLValue(valueStr);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
i++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
data: isArray && items.length > 0 ? items : result,
|
|
280
|
+
i,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parse a YAML array value
|
|
286
|
+
*/
|
|
287
|
+
function parseYAMLArray(str) {
|
|
288
|
+
if (!str.startsWith('[') || !str.endsWith(']')) {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
const content = str.slice(1, -1).trim();
|
|
292
|
+
if (!content) return [];
|
|
293
|
+
return content.split(',').map((item) => parseYAMLValue(item.trim()));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Parse a YAML object value
|
|
298
|
+
*/
|
|
299
|
+
function parseYAMLObject(str) {
|
|
300
|
+
if (!str.startsWith('{') || !str.endsWith('}')) {
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
const content = str.slice(1, -1).trim();
|
|
304
|
+
if (!content) return {};
|
|
305
|
+
|
|
306
|
+
const obj = {};
|
|
307
|
+
const pairs = content.split(',');
|
|
308
|
+
for (const pair of pairs) {
|
|
309
|
+
const [key, ...valueParts] = pair.split(':');
|
|
310
|
+
if (key) {
|
|
311
|
+
obj[key.trim()] = parseYAMLValue(valueParts.join(':').trim());
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return obj;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Parse a single YAML value (string, number, boolean, null)
|
|
319
|
+
*/
|
|
320
|
+
function parseYAMLValue(str) {
|
|
321
|
+
if (!str) return null;
|
|
322
|
+
str = str.trim();
|
|
323
|
+
|
|
324
|
+
if (str === 'null' || str === '~') return null;
|
|
325
|
+
if (str === 'true') return true;
|
|
326
|
+
if (str === 'false') return false;
|
|
327
|
+
if (str === '[]') return [];
|
|
328
|
+
if (str === '{}') return {};
|
|
329
|
+
|
|
330
|
+
if (str.startsWith('"') && str.endsWith('"')) {
|
|
331
|
+
return str.slice(1, -1);
|
|
332
|
+
}
|
|
333
|
+
if (str.startsWith("'") && str.endsWith("'")) {
|
|
334
|
+
return str.slice(1, -1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check if it's a number (but not a version string like 1.0)
|
|
338
|
+
// Version strings contain dots - keep as string
|
|
339
|
+
if (str.includes('.') && str.split('.').length === 2 && str.split('.').every(part => /^\d+$/.test(part))) {
|
|
340
|
+
// This looks like a version string (e.g., 1.0, 2.1.5)
|
|
341
|
+
return str;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const num = Number(str);
|
|
345
|
+
if (!isNaN(num) && str !== '' && !str.includes('.')) return num;
|
|
346
|
+
|
|
347
|
+
return str;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Validate YAML syntax by attempting to parse it
|
|
352
|
+
* @param {string} content - YAML content to validate
|
|
353
|
+
* @returns {object} { valid: boolean, errors: string[] }
|
|
354
|
+
*/
|
|
355
|
+
export function validateYAML(content) {
|
|
356
|
+
if (!content || typeof content !== 'string') {
|
|
357
|
+
return { valid: true, errors: [] };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
parseYAML(content);
|
|
362
|
+
return { valid: true, errors: [] };
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return { valid: false, errors: [error.message] };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Serialize JavaScript object back to YAML string
|
|
370
|
+
* @param {object} obj - Object to serialize
|
|
371
|
+
* @param {number} indent - Starting indentation level
|
|
372
|
+
* @returns {string} YAML string
|
|
373
|
+
*/
|
|
374
|
+
export function serializeYAML(obj, indent = 0) {
|
|
375
|
+
if (obj === null || obj === undefined) return '';
|
|
376
|
+
|
|
377
|
+
const lines = [];
|
|
378
|
+
const indentStr = ' '.repeat(indent);
|
|
379
|
+
const nextIndentStr = ' '.repeat(indent + 2);
|
|
380
|
+
|
|
381
|
+
if (Array.isArray(obj)) {
|
|
382
|
+
for (const item of obj) {
|
|
383
|
+
if (
|
|
384
|
+
typeof item === 'object' &&
|
|
385
|
+
item !== null &&
|
|
386
|
+
!Array.isArray(item)
|
|
387
|
+
) {
|
|
388
|
+
// For objects in arrays, serialize the first property inline if simple
|
|
389
|
+
const keys = Object.keys(item);
|
|
390
|
+
if (keys.length === 1) {
|
|
391
|
+
const key = keys[0];
|
|
392
|
+
const value = item[key];
|
|
393
|
+
const serialized = serializeYAMLValue(value);
|
|
394
|
+
lines.push(indentStr + '- ' + key + ': ' + serialized);
|
|
395
|
+
} else {
|
|
396
|
+
// Multiple properties - inline first, rest below
|
|
397
|
+
const serialized = serializeYAML(item, indent + 2).trim();
|
|
398
|
+
lines.push(indentStr + '- ' + serialized);
|
|
399
|
+
}
|
|
400
|
+
} else if (Array.isArray(item)) {
|
|
401
|
+
lines.push(
|
|
402
|
+
indentStr + '-\n' + serializeYAML(item, indent + 2)
|
|
403
|
+
);
|
|
404
|
+
} else {
|
|
405
|
+
const value = serializeYAMLValue(item);
|
|
406
|
+
lines.push(indentStr + '- ' + value);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} else if (typeof obj === 'object') {
|
|
410
|
+
// Sort keys to maintain consistent ordering
|
|
411
|
+
const keys = Object.keys(obj).sort();
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
const value = obj[key];
|
|
414
|
+
if (value === null || value === undefined) {
|
|
415
|
+
lines.push(nextIndentStr + key + ': null');
|
|
416
|
+
} else if (Array.isArray(value)) {
|
|
417
|
+
lines.push(nextIndentStr + key + ':');
|
|
418
|
+
for (const item of value) {
|
|
419
|
+
if (typeof item === 'object' && item !== null) {
|
|
420
|
+
const itemSerialized = serializeYAML(item, indent + 4).trim();
|
|
421
|
+
lines.push(nextIndentStr + ' - ' + itemSerialized);
|
|
422
|
+
} else {
|
|
423
|
+
lines.push(
|
|
424
|
+
nextIndentStr +
|
|
425
|
+
' - ' +
|
|
426
|
+
serializeYAMLValue(item)
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else if (
|
|
431
|
+
typeof value === 'object' &&
|
|
432
|
+
!Array.isArray(value)
|
|
433
|
+
) {
|
|
434
|
+
lines.push(nextIndentStr + key + ':');
|
|
435
|
+
lines.push(serializeYAML(value, indent + 4));
|
|
436
|
+
} else {
|
|
437
|
+
const serialized = serializeYAMLValue(value);
|
|
438
|
+
lines.push(nextIndentStr + key + ': ' + serialized);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return lines.join('\n');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Serialize a single value for YAML
|
|
448
|
+
*/
|
|
449
|
+
function serializeYAMLValue(value) {
|
|
450
|
+
if (value === null || value === undefined) return 'null';
|
|
451
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
452
|
+
if (typeof value === 'number') return String(value);
|
|
453
|
+
if (typeof value === 'string') {
|
|
454
|
+
if (value.includes(':') || value.includes('#') || value.includes('"')) {
|
|
455
|
+
return '"' + value.replace(/"/g, '\\"') + '"';
|
|
456
|
+
}
|
|
457
|
+
return value;
|
|
458
|
+
}
|
|
459
|
+
return String(value);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// MergeChangelog - Tracks what changed during merge
|
|
464
|
+
// ============================================================================
|
|
465
|
+
|
|
466
|
+
class MergeChangelog {
|
|
467
|
+
constructor() {
|
|
468
|
+
this.added = [];
|
|
469
|
+
this.deduplicated = [];
|
|
470
|
+
this.preserved = [];
|
|
471
|
+
this.merged = [];
|
|
472
|
+
this.errors = [];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
recordAdded(section, items) {
|
|
476
|
+
this.added.push({
|
|
477
|
+
section,
|
|
478
|
+
items: items.length > 0 ? items : null,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
recordDeduplicated(section, count) {
|
|
483
|
+
if (count > 0) {
|
|
484
|
+
this.deduplicated.push({ section, count });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
recordPreserved(path) {
|
|
489
|
+
this.preserved.push(path);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
recordMerged(section, keys) {
|
|
493
|
+
this.merged.push({ section, keys });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
recordError(error) {
|
|
497
|
+
this.errors.push(error);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
toJSON() {
|
|
501
|
+
return {
|
|
502
|
+
added: this.added,
|
|
503
|
+
deduplicated: this.deduplicated,
|
|
504
|
+
preserved: this.preserved,
|
|
505
|
+
merged: this.merged,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ============================================================================
|
|
511
|
+
// MergeResult - Represents the outcome of a merge operation
|
|
512
|
+
// ============================================================================
|
|
513
|
+
|
|
514
|
+
class MergeResult {
|
|
515
|
+
constructor() {
|
|
516
|
+
this.success = true;
|
|
517
|
+
this.data = {};
|
|
518
|
+
this.errors = [];
|
|
519
|
+
this.warnings = [];
|
|
520
|
+
this.changelog = new MergeChangelog();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Convert merged data back to YAML string
|
|
525
|
+
*/
|
|
526
|
+
toYAML() {
|
|
527
|
+
try {
|
|
528
|
+
if (!this.data || typeof this.data !== 'object') {
|
|
529
|
+
return '';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const lines = [];
|
|
533
|
+
for (const [key, value] of Object.entries(this.data)) {
|
|
534
|
+
if (value === null || value === undefined) {
|
|
535
|
+
lines.push(key + ': null');
|
|
536
|
+
} else if (Array.isArray(value)) {
|
|
537
|
+
lines.push(key + ':');
|
|
538
|
+
for (const item of value) {
|
|
539
|
+
if (typeof item === 'object' && item !== null) {
|
|
540
|
+
const serialized = serializeYAML(item, 2).trim();
|
|
541
|
+
lines.push(' - ' + serialized);
|
|
542
|
+
} else {
|
|
543
|
+
const serialized = serializeYAMLValue(item);
|
|
544
|
+
lines.push(' - ' + serialized);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} else if (typeof value === 'object') {
|
|
548
|
+
lines.push(key + ':');
|
|
549
|
+
const serialized = serializeYAML(value, 2);
|
|
550
|
+
lines.push(serialized);
|
|
551
|
+
} else {
|
|
552
|
+
const serialized = serializeYAMLValue(value);
|
|
553
|
+
lines.push(key + ': ' + serialized);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return lines.join('\n');
|
|
558
|
+
} catch (error) {
|
|
559
|
+
this.errors.push('Serialization error: ' + error.message);
|
|
560
|
+
this.success = false;
|
|
561
|
+
return '';
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Validate that merged result is valid YAML
|
|
567
|
+
*/
|
|
568
|
+
validate() {
|
|
569
|
+
const errors = [];
|
|
570
|
+
|
|
571
|
+
if (!this.data || typeof this.data !== 'object') {
|
|
572
|
+
errors.push('Merged data is not an object');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Check for valid YAML serialization
|
|
576
|
+
try {
|
|
577
|
+
this.toYAML();
|
|
578
|
+
} catch (error) {
|
|
579
|
+
errors.push('Invalid YAML output: ' + error.message);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return errors;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ============================================================================
|
|
587
|
+
// Main Merge Function
|
|
588
|
+
// ============================================================================
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Merges two YAML configuration strings intelligently
|
|
592
|
+
* Arrays are deduplicated using deep equality
|
|
593
|
+
* Objects are recursively merged with new keys added and existing keys preserved
|
|
594
|
+
*
|
|
595
|
+
* @param {string} existing - Current configuration (file content or empty string)
|
|
596
|
+
* @param {string} update - New configuration to merge
|
|
597
|
+
* @returns {MergeResult} Result object with merged data, success status, errors, warnings, and changelog
|
|
598
|
+
*/
|
|
599
|
+
export function mergeYAML(existing, update) {
|
|
600
|
+
const result = new MergeResult();
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
// Validate inputs
|
|
604
|
+
if (typeof existing !== 'string') {
|
|
605
|
+
result.errors.push('existing parameter must be a string');
|
|
606
|
+
result.success = false;
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
if (typeof update !== 'string') {
|
|
610
|
+
result.errors.push('update parameter must be a string');
|
|
611
|
+
result.success = false;
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Parse both YAML strings
|
|
616
|
+
const existingParsed = parseYAML(existing);
|
|
617
|
+
const updateParsed = parseYAML(update);
|
|
618
|
+
|
|
619
|
+
if (existingParsed.parseError) {
|
|
620
|
+
result.errors.push('Existing YAML parse error: ' + existingParsed.parseError);
|
|
621
|
+
result.success = false;
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (updateParsed.parseError) {
|
|
626
|
+
result.errors.push('Update YAML parse error: ' + updateParsed.parseError);
|
|
627
|
+
result.success = false;
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const existingData = existingParsed.data || {};
|
|
632
|
+
const updateData = updateParsed.data || {};
|
|
633
|
+
|
|
634
|
+
// Merge the data
|
|
635
|
+
result.data = mergeObjectsWithChangelog(
|
|
636
|
+
existingData,
|
|
637
|
+
updateData,
|
|
638
|
+
result.changelog
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// Validate output
|
|
642
|
+
const validationErrors = result.validate();
|
|
643
|
+
if (validationErrors.length > 0) {
|
|
644
|
+
result.errors.push(...validationErrors);
|
|
645
|
+
result.success = false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return result;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
result.errors.push('Merge error: ' + error.message);
|
|
651
|
+
result.success = false;
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Merge objects while recording changelog
|
|
658
|
+
*/
|
|
659
|
+
function mergeObjectsWithChangelog(existing, update, changelog) {
|
|
660
|
+
const result = { ...existing };
|
|
661
|
+
|
|
662
|
+
for (const [key, updateValue] of Object.entries(update)) {
|
|
663
|
+
if (key in result) {
|
|
664
|
+
const existingValue = result[key];
|
|
665
|
+
|
|
666
|
+
if (Array.isArray(existingValue) && Array.isArray(updateValue)) {
|
|
667
|
+
const { result: merged, deduped } = deduplicateArrays(
|
|
668
|
+
existingValue,
|
|
669
|
+
updateValue
|
|
670
|
+
);
|
|
671
|
+
result[key] = merged;
|
|
672
|
+
if (deduped > 0) {
|
|
673
|
+
changelog.recordDeduplicated(key, deduped);
|
|
674
|
+
}
|
|
675
|
+
} else if (
|
|
676
|
+
typeof existingValue === 'object' &&
|
|
677
|
+
existingValue !== null &&
|
|
678
|
+
!Array.isArray(existingValue) &&
|
|
679
|
+
typeof updateValue === 'object' &&
|
|
680
|
+
updateValue !== null &&
|
|
681
|
+
!Array.isArray(updateValue)
|
|
682
|
+
) {
|
|
683
|
+
result[key] = mergeObjectsWithChangelog(
|
|
684
|
+
existingValue,
|
|
685
|
+
updateValue,
|
|
686
|
+
changelog
|
|
687
|
+
);
|
|
688
|
+
} else if (existingValue !== updateValue) {
|
|
689
|
+
// Values differ - update to new value (tool updates should be applied)
|
|
690
|
+
result[key] = updateValue;
|
|
691
|
+
}
|
|
692
|
+
// If values are equal, no change needed
|
|
693
|
+
} else {
|
|
694
|
+
result[key] = updateValue;
|
|
695
|
+
if (Array.isArray(updateValue)) {
|
|
696
|
+
changelog.recordAdded(key, updateValue);
|
|
697
|
+
} else if (typeof updateValue === 'object') {
|
|
698
|
+
changelog.recordAdded(key, []);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return result;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ============================================================================
|
|
707
|
+
// Exports (including classes for advanced usage)
|
|
708
|
+
// ============================================================================
|
|
709
|
+
|
|
710
|
+
export { MergeResult, MergeChangelog };
|