release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,669 @@
1
+ /**
2
+ * YAML format-preserving three-way merge.
3
+ *
4
+ * Uses YAML CST (via `yaml` library with `keepSourceTokens: true`) to
5
+ * locate and modify values at specific paths. Comments, anchors/aliases,
6
+ * key order, and scalar styles are preserved by using CST ranges only for
7
+ * locating minimal token replacements; output is never a full re-serialize.
8
+ *
9
+ * If any modification would lose a comment, anchor, alias, scalar style,
10
+ * or key order, the merge fails closed to CONFLICT.
11
+ *
12
+ * Sequences (arrays) without an identity key that are modified on both
13
+ * sides always conflict.
14
+ *
15
+ * @module artifacts/merge/yaml
16
+ */
17
+
18
+ import YAML from 'yaml';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // YAML path helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Resolve a slash-separated path (e.g. "/deploy/replicas") to a CST node
26
+ * in a YAML Document.
27
+ *
28
+ * @param {YAML.Document} doc
29
+ * @param {string} path - Slash-separated YAML path
30
+ * @returns {YAML.Pair|YAML.Scalar|YAML.YAMLMap|YAML.YAMLSeq|null}
31
+ */
32
+ function resolvePath(doc, path) {
33
+ const parts = path.split('/').filter(Boolean);
34
+ let node = doc.contents;
35
+
36
+ for (const part of parts) {
37
+ if (node == null) return null;
38
+
39
+ if (node.items) {
40
+ // It's a YAMLMap or YAMLSeq
41
+ if (typeof node.get === 'function') {
42
+ // YAMLMap — find by key
43
+ const pair = node.items.find((item) => {
44
+ if (item.key && item.key.value === part) return true;
45
+ return false;
46
+ });
47
+ if (!pair) return null;
48
+ node = pair.value;
49
+ } else {
50
+ return null;
51
+ }
52
+ } else {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ return node;
58
+ }
59
+
60
+ /**
61
+ * Find the YAML.Pair that contains a given path's leaf value.
62
+ *
63
+ * @param {YAML.Document} doc
64
+ * @param {string} path
65
+ * @returns {YAML.Pair|null}
66
+ */
67
+ function resolvePair(doc, path) {
68
+ const parts = path.split('/').filter(Boolean);
69
+ if (parts.length === 0) return null;
70
+
71
+ let node = doc.contents;
72
+ for (let i = 0; i < parts.length - 1; i++) {
73
+ if (node == null || !node.items) return null;
74
+ const pair = node.items.find((item) =>
75
+ item.key && item.key.value === parts[i],
76
+ );
77
+ if (!pair) return null;
78
+ node = pair.value;
79
+ }
80
+
81
+ if (node == null || !node.items) return null;
82
+ return node.items.find((item) =>
83
+ item.key && item.key.value === parts[parts.length - 1],
84
+ ) ?? null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // CST feature preservation checks
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Check whether a YAML Scalar node has features that would be lost by
93
+ * a simple value replacement.
94
+ *
95
+ * @param {YAML.Scalar} node
96
+ * @returns {{ ok: boolean, reason?: string }}
97
+ */
98
+ function scalarPreservationCheck(node) {
99
+ if (!node) return { ok: true };
100
+
101
+ // Check for anchor
102
+ if (node.anchor) {
103
+ return { ok: false, reason: `anchor '${node.anchor}' on scalar` };
104
+ }
105
+
106
+ // Check for comment
107
+ if (node.comment || node.commentBefore) {
108
+ return { ok: false, reason: 'comment on scalar' };
109
+ }
110
+
111
+ // Check scalar type/style — if it's a block scalar or quoted, replacing
112
+ // the value might lose the style
113
+ if (node.type) {
114
+ const preserveStyleTypes = [
115
+ 'BLOCK_FOLDED', 'BLOCK_LITERAL', 'QUOTE_DOUBLE', 'QUOTE_SINGLE',
116
+ ];
117
+ if (preserveStyleTypes.includes(node.type)) {
118
+ // We can preserve the style by setting the value and keeping the type
119
+ return { ok: true, preserveType: node.type };
120
+ }
121
+ }
122
+
123
+ return { ok: true };
124
+ }
125
+
126
+ /**
127
+ * Check whether a YAML node tree has any comments, anchors, or aliases
128
+ * that would be lost by stringify.
129
+ *
130
+ * @param {YAML.Node} node
131
+ * @returns {{ ok: boolean, reason?: string }}
132
+ */
133
+ function deepFeatureCheck(node) {
134
+ if (!node) return { ok: true };
135
+
136
+ if (node.comment || node.commentBefore) {
137
+ return { ok: false, reason: 'comment found' };
138
+ }
139
+
140
+ if (node.anchor) {
141
+ return { ok: false, reason: `anchor '${node.anchor}' found` };
142
+ }
143
+
144
+ // Check for aliases
145
+ if (node.type === 'ALIAS') {
146
+ return { ok: false, reason: `alias '${node.source}' found` };
147
+ }
148
+
149
+ // Recurse into map/seq items
150
+ if (node.items) {
151
+ for (const item of node.items) {
152
+ if (item.key) {
153
+ const keyCheck = deepFeatureCheck(item.key);
154
+ if (!keyCheck.ok) return keyCheck;
155
+ }
156
+ if (item.value) {
157
+ const valCheck = deepFeatureCheck(item.value);
158
+ if (!valCheck.ok) return valCheck;
159
+ }
160
+ }
161
+ }
162
+
163
+ return { ok: true };
164
+ }
165
+
166
+ /**
167
+ * Compare two YAML parsed structures for value equality (ignoring
168
+ * formatting).
169
+ *
170
+ * @param {*} a
171
+ * @param {*} b
172
+ * @returns {boolean}
173
+ */
174
+ function yamlValueEqual(a, b) {
175
+ if (a === b) return true;
176
+ if (a == null || b == null) return a === b;
177
+ if (typeof a !== typeof b) return false;
178
+ if (typeof a !== 'object') return a === b;
179
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
180
+ const keysA = Object.keys(a);
181
+ const keysB = Object.keys(b);
182
+ if (keysA.length !== keysB.length) return false;
183
+ for (const key of keysA) {
184
+ if (!yamlValueEqual(a[key], b[key])) return false;
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Serialize a JS value to YAML string preserving the original scalar
191
+ * style when possible.
192
+ *
193
+ * @param {*} value
194
+ * @param {object} [styleOpts]
195
+ * @returns {string}
196
+ */
197
+ function yamlSerializeValue(value, styleOpts = {}) {
198
+ if (value === null || value === undefined) return 'null';
199
+ if (typeof value === 'boolean') return String(value);
200
+ if (typeof value === 'number') return String(value);
201
+ if (typeof value === 'string') {
202
+ if (styleOpts.type === 'BLOCK_FOLDED') return YAML.stringify(value, { defaultKeyType: 'PLAIN' }).trim();
203
+ if (styleOpts.type === 'BLOCK_LITERAL') return YAML.stringify(value, { defaultKeyType: 'PLAIN' }).trim();
204
+ if (styleOpts.type === 'QUOTE_DOUBLE') return JSON.stringify(value);
205
+ if (styleOpts.type === 'QUOTE_SINGLE') return `'${value}'`;
206
+ // Default: use YAML's default
207
+ return YAML.stringify(value).trim();
208
+ }
209
+ return YAML.stringify(value).trim();
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Public API
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Merge two YAML modifications of the same base content.
218
+ *
219
+ * Modifies CST nodes at the specified paths. Comments, anchors, aliases,
220
+ * key order, and scalar styles are preserved via CST round-trip.
221
+ *
222
+ * Sequences modified on both sides without an identity key always conflict.
223
+ *
224
+ * @param {object} options
225
+ * @param {Buffer} options.base - Base YAML content.
226
+ * @param {Buffer} options.current - Current (human) YAML content.
227
+ * @param {Buffer} options.generated - Generated (producer) YAML content.
228
+ * @param {string[]} [options.paths=[]] - YAML paths of values to merge.
229
+ * @param {Record<string,string>} [options.identityKeys={}]
230
+ * Map of YAML path → identity key name for sequences at that path.
231
+ * @returns {{ status: 'MERGEABLE'|'CONFLICT'|'STRUCTURE_INVALID', bytes?: Buffer, conflicts: object[] }}
232
+ */
233
+ export function mergeYaml({ base, current, generated, paths = [], identityKeys = {} } = {}) {
234
+ // Null/missing inputs → CONFLICT
235
+ if (!base || !current || !generated) {
236
+ return Object.freeze({
237
+ status: 'CONFLICT',
238
+ bytes: undefined,
239
+ conflicts: Object.freeze([{ reason: 'missing input' }]),
240
+ });
241
+ }
242
+
243
+ // Parse all three sides with CST
244
+ let baseDoc, currentDoc, genDoc;
245
+ try {
246
+ baseDoc = YAML.parseDocument(base.toString('utf8'), { keepSourceTokens: true });
247
+ currentDoc = YAML.parseDocument(current.toString('utf8'), { keepSourceTokens: true });
248
+ genDoc = YAML.parseDocument(generated.toString('utf8'), { keepSourceTokens: true });
249
+ } catch {
250
+ return Object.freeze({
251
+ status: 'STRUCTURE_INVALID',
252
+ bytes: undefined,
253
+ conflicts: Object.freeze([{ reason: 'malformed YAML input' }]),
254
+ });
255
+ }
256
+
257
+ // Check for parse errors (YAML parser is lenient, errors are in .errors)
258
+ if (baseDoc.errors?.length > 0 || currentDoc.errors?.length > 0 || genDoc.errors?.length > 0) {
259
+ return Object.freeze({
260
+ status: 'STRUCTURE_INVALID',
261
+ bytes: undefined,
262
+ conflicts: Object.freeze([{ reason: 'YAML parse errors detected' }]),
263
+ });
264
+ }
265
+
266
+ // Collect edits to apply to currentDoc
267
+ const edits = [];
268
+ const conflicts = [];
269
+
270
+ for (const path of paths) {
271
+ const baseValue = baseDoc.getIn(path.split('/').filter(Boolean));
272
+ const currentValue = currentDoc.getIn(path.split('/').filter(Boolean));
273
+ const genValue = genDoc.getIn(path.split('/').filter(Boolean));
274
+
275
+ const safeStringify = (v) => {
276
+ if (v === undefined || v === null) return String(v);
277
+ try { return YAML.stringify(v).trim(); } catch { return String(v); }
278
+ };
279
+ const baseStr = safeStringify(baseValue);
280
+ const currentStr = safeStringify(currentValue);
281
+ const genStr = safeStringify(genValue);
282
+
283
+ const currentChanged = baseStr !== currentStr;
284
+ const genChanged = baseStr !== genStr;
285
+
286
+ if (!currentChanged && !genChanged) {
287
+ continue; // Both unchanged
288
+ }
289
+
290
+ if (currentChanged && genChanged) {
291
+ if (currentStr === genStr) {
292
+ continue; // Same change — already in current
293
+ }
294
+
295
+ // Check if this is a sequence without identity key
296
+ const currentValueNode = resolvePath(currentDoc, path);
297
+ if (currentValueNode && currentValueNode.items && Array.isArray(currentValueNode.items)) {
298
+ // It's a sequence (YAMLSeq)
299
+ const idKey = identityKeys[path];
300
+ if (!idKey) {
301
+ conflicts.push({ path, reason: 'sequence modified on both sides without identity key' });
302
+ continue;
303
+ }
304
+ // Identity-keyed sequence merge
305
+ const merged = mergeIdentityKeyedSequence(
306
+ baseDoc, currentDoc, genDoc, path, idKey,
307
+ );
308
+ if (merged === 'CONFLICT') {
309
+ conflicts.push({ path, reason: 'identity-keyed sequence merge conflict' });
310
+ continue;
311
+ }
312
+ if (merged !== 'CLEAN') {
313
+ edits.push({ path, value: merged.value, node: merged.node });
314
+ }
315
+ continue;
316
+ }
317
+
318
+ // Scalar/other divergent change — conflict
319
+ conflicts.push({ path, reason: 'divergent modification' });
320
+ continue;
321
+ }
322
+
323
+ if (!currentChanged && genChanged) {
324
+ // Only generated changed — apply it
325
+ const currentNode = resolvePath(currentDoc, path);
326
+ if (!currentNode) {
327
+ conflicts.push({ path, reason: 'path not found in current' });
328
+ continue;
329
+ }
330
+
331
+ // Check if modifying would lose CST features
332
+ const check = scalarPreservationCheck(currentNode);
333
+ if (!check.ok) {
334
+ conflicts.push({ path, reason: check.reason });
335
+ continue;
336
+ }
337
+
338
+ edits.push({ path, value: genValue, node: currentNode, preserveType: check.preserveType });
339
+ }
340
+ // If only current changed — keep current (no edit needed)
341
+ }
342
+
343
+ if (conflicts.length > 0) {
344
+ return Object.freeze({
345
+ status: 'CONFLICT',
346
+ bytes: undefined,
347
+ conflicts: Object.freeze(conflicts.map((c) => Object.freeze(c))),
348
+ });
349
+ }
350
+
351
+ if (edits.length === 0) {
352
+ // No changes — return current bytes as-is
353
+ return Object.freeze({
354
+ status: 'MERGEABLE',
355
+ bytes: Buffer.from(current),
356
+ conflicts: Object.freeze([]),
357
+ });
358
+ }
359
+
360
+ // Apply edits via CST node.range minimal byte replacement.
361
+ // This preserves every byte of current outside the edited token range
362
+ // (indentation, blank lines, EOL comments, anchors, aliases, quoted/block style).
363
+ // If any node lacks a range or the replacement cannot be localised, fail closed.
364
+ const currentSrc = current.toString('utf8');
365
+ /** @type {Array<{ start: number, end: number, replacement: string }>} */
366
+ const byteEdits = [];
367
+
368
+ for (const edit of edits) {
369
+ // Locate the leaf value node in currentDoc CST
370
+ const parts = edit.path.split('/').filter(Boolean);
371
+ const leafKey = parts[parts.length - 1];
372
+ const parentPath = parts.slice(0, -1);
373
+ let parentNode = parentPath.length === 0 ? currentDoc.contents : currentDoc;
374
+
375
+ for (const part of parentPath) {
376
+ parentNode = parentNode.get(part, true);
377
+ if (!parentNode) break;
378
+ }
379
+
380
+ if (!parentNode || !parentNode.items) {
381
+ return Object.freeze({
382
+ status: 'CONFLICT',
383
+ bytes: undefined,
384
+ conflicts: Object.freeze([{
385
+ reason: `cannot locate parent for path '${edit.path}' in CST`,
386
+ }]),
387
+ });
388
+ }
389
+
390
+ const pair = parentNode.items.find((item) =>
391
+ item.key && item.key.value === leafKey,
392
+ );
393
+ if (!pair || !pair.value) {
394
+ return Object.freeze({
395
+ status: 'CONFLICT',
396
+ bytes: undefined,
397
+ conflicts: Object.freeze([{
398
+ reason: `cannot locate value node for path '${edit.path}' in CST`,
399
+ }]),
400
+ });
401
+ }
402
+
403
+ const valueNode = pair.value;
404
+
405
+ // Check for CST features that prevent localised replacement
406
+ const presCheck = scalarPreservationCheck(valueNode);
407
+ if (!presCheck.ok) {
408
+ return Object.freeze({
409
+ status: 'CONFLICT',
410
+ bytes: undefined,
411
+ conflicts: Object.freeze([{ reason: presCheck.reason }]),
412
+ });
413
+ }
414
+
415
+ // Get the node's range in the source. The YAML CST library provides
416
+ // range as [start, end, nodeEnd] when keepSourceTokens is true.
417
+ // We need the value token range specifically.
418
+ const nodeRange = valueNode.range;
419
+ if (!nodeRange || nodeRange.length < 2 || nodeRange[0] == null || nodeRange[1] == null) {
420
+ // Cannot localise — fail closed
421
+ return Object.freeze({
422
+ status: 'CONFLICT',
423
+ bytes: undefined,
424
+ conflicts: Object.freeze([{
425
+ reason: `CST node.range unavailable for path '${edit.path}' — cannot localise edit`,
426
+ }]),
427
+ });
428
+ }
429
+
430
+ const rangeStart = nodeRange[0];
431
+ const rangeEnd = nodeRange[1];
432
+
433
+ // Only scalar token replacement is proven byte-preserving in v1.
434
+ if (valueNode.items || (edit.value !== null && typeof edit.value === 'object')) {
435
+ return Object.freeze({
436
+ status: 'CONFLICT',
437
+ bytes: undefined,
438
+ conflicts: Object.freeze([{ reason: `non-scalar edit at '${edit.path}' requires manual resolution` }]),
439
+ });
440
+ }
441
+
442
+ // Serialise the replacement value preserving the original scalar style.
443
+ let replacementText;
444
+ if (typeof edit.value === 'string') {
445
+ // Preserve quoting style from the original node
446
+ if (valueNode.type === 'QUOTE_DOUBLE') {
447
+ replacementText = JSON.stringify(edit.value);
448
+ } else if (valueNode.type === 'QUOTE_SINGLE') {
449
+ replacementText = `'${edit.value.replaceAll("'", "''")}'`;
450
+ } else if (valueNode.type === 'BLOCK_FOLDED' || valueNode.type === 'BLOCK_LITERAL') {
451
+ return Object.freeze({
452
+ status: 'CONFLICT',
453
+ bytes: undefined,
454
+ conflicts: Object.freeze([{ reason: `block scalar edit at '${edit.path}' requires manual resolution` }]),
455
+ });
456
+ } else {
457
+ replacementText = edit.value;
458
+ }
459
+ } else if (typeof edit.value === 'number' || typeof edit.value === 'boolean') {
460
+ replacementText = String(edit.value);
461
+ } else {
462
+ replacementText = edit.value === null ? 'null' : String(edit.value);
463
+ }
464
+
465
+ try {
466
+ const parsedReplacement = YAML.parse(replacementText);
467
+ if (!yamlValueEqual(parsedReplacement, edit.value)) {
468
+ return Object.freeze({
469
+ status: 'CONFLICT',
470
+ bytes: undefined,
471
+ conflicts: Object.freeze([{ reason: `scalar style cannot safely represent value at '${edit.path}'` }]),
472
+ });
473
+ }
474
+ } catch {
475
+ return Object.freeze({
476
+ status: 'CONFLICT',
477
+ bytes: undefined,
478
+ conflicts: Object.freeze([{ reason: `invalid scalar replacement at '${edit.path}'` }]),
479
+ });
480
+ }
481
+
482
+ byteEdits.push({ start: rangeStart, end: rangeEnd, replacement: replacementText });
483
+ }
484
+
485
+ if (byteEdits.length === 0) {
486
+ return Object.freeze({
487
+ status: 'MERGEABLE',
488
+ bytes: Buffer.from(current),
489
+ conflicts: Object.freeze([]),
490
+ });
491
+ }
492
+
493
+ // Apply byte edits in reverse order (highest offset first)
494
+ byteEdits.sort((a, b) => b.start - a.start);
495
+
496
+ let output = currentSrc;
497
+ for (const edit of byteEdits) {
498
+ output = output.substring(0, edit.start) + edit.replacement + output.substring(edit.end);
499
+ }
500
+
501
+ // Verify preservation: every comment, anchor, and alias from current must survive
502
+ const commentLines = currentSrc.split('\n')
503
+ .filter((line) => line.trimStart().startsWith('#'))
504
+ .map((line) => line.trim());
505
+
506
+ for (const comment of commentLines) {
507
+ if (!output.includes(comment)) {
508
+ return Object.freeze({
509
+ status: 'CONFLICT',
510
+ bytes: undefined,
511
+ conflicts: Object.freeze([{
512
+ reason: `comment lost during merge: ${comment}`,
513
+ }]),
514
+ });
515
+ }
516
+ }
517
+
518
+ const anchorPattern = /&[\w-]+/g;
519
+ const aliasPattern = /\*[\w-]+/g;
520
+ const anchors = currentSrc.match(anchorPattern) ?? [];
521
+ const aliases = currentSrc.match(aliasPattern) ?? [];
522
+
523
+ for (const anchor of anchors) {
524
+ if (!output.includes(anchor)) {
525
+ return Object.freeze({
526
+ status: 'CONFLICT',
527
+ bytes: undefined,
528
+ conflicts: Object.freeze([{
529
+ reason: `anchor lost during merge: ${anchor}`,
530
+ }]),
531
+ });
532
+ }
533
+ }
534
+
535
+ for (const alias of aliases) {
536
+ if (!output.includes(alias)) {
537
+ return Object.freeze({
538
+ status: 'CONFLICT',
539
+ bytes: undefined,
540
+ conflicts: Object.freeze([{
541
+ reason: `alias lost during merge: ${alias}`,
542
+ }]),
543
+ });
544
+ }
545
+ }
546
+
547
+ return Object.freeze({
548
+ status: 'MERGEABLE',
549
+ bytes: Buffer.from(output, 'utf8'),
550
+ conflicts: Object.freeze([]),
551
+ });
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // Identity-keyed sequence merge
556
+ // ---------------------------------------------------------------------------
557
+
558
+ /**
559
+ * Merge a YAML sequence at a given path using an identity key.
560
+ *
561
+ * @param {YAML.Document} baseDoc
562
+ * @param {YAML.Document} currentDoc
563
+ * @param {YAML.Document} genDoc
564
+ * @param {string} path
565
+ * @param {string} identityKey
566
+ * @returns {{ value: any, node: object }|'CLEAN'|'CONFLICT'}
567
+ */
568
+ function mergeIdentityKeyedSequence(baseDoc, currentDoc, genDoc, path, identityKey) {
569
+ const parts = path.split('/').filter(Boolean);
570
+
571
+ const baseSeq = baseDoc.getIn(parts, true);
572
+ const currentSeq = currentDoc.getIn(parts, true);
573
+ const genSeq = genDoc.getIn(parts, true);
574
+
575
+ if (!baseSeq || !currentSeq || !genSeq) return 'CONFLICT';
576
+ if (!baseSeq.items || !currentSeq.items || !genSeq.items) return 'CONFLICT';
577
+
578
+ // Index by identity key — each item is a YAMLMap with keys
579
+ const indexBy = (seq) => {
580
+ const map = new Map();
581
+ for (const item of seq.items) {
582
+ if (item.items) {
583
+ const pair = item.items.find((p) => p.key && p.key.value === identityKey);
584
+ if (pair) {
585
+ const keyValue = pair.value?.value ?? pair.value;
586
+ map.set(String(keyValue), item);
587
+ }
588
+ }
589
+ }
590
+ return map;
591
+ };
592
+
593
+ const baseMap = indexBy(baseSeq);
594
+ const currentMap = indexBy(currentSeq);
595
+ const genMap = indexBy(genSeq);
596
+
597
+ const allKeys = new Set([...baseMap.keys(), ...currentMap.keys(), ...genMap.keys()]);
598
+ const merged = [];
599
+
600
+ for (const key of allKeys) {
601
+ const b = baseMap.get(key);
602
+ const c = currentMap.get(key);
603
+ const g = genMap.get(key);
604
+
605
+ if (!b && c && g) {
606
+ // Both added — check same
607
+ const cVal = YAML.stringify(c).trim();
608
+ const gVal = YAML.stringify(g).trim();
609
+ if (cVal !== gVal) return 'CONFLICT';
610
+ merged.push(c);
611
+ } else if (!b && c && !g) {
612
+ merged.push(c);
613
+ } else if (!b && !c && g) {
614
+ merged.push(g);
615
+ } else if (b && c && !g) {
616
+ const bVal = YAML.stringify(b).trim();
617
+ const cVal = YAML.stringify(c).trim();
618
+ if (bVal !== cVal) return 'CONFLICT';
619
+ // Producer deleted, human unchanged → accept delete
620
+ } else if (b && !c && g) {
621
+ const bVal = YAML.stringify(b).trim();
622
+ const gVal = YAML.stringify(g).trim();
623
+ if (bVal !== gVal) return 'CONFLICT';
624
+ // Human deleted, producer unchanged → accept delete
625
+ } else if (b && !c && !g) {
626
+ // Both deleted
627
+ } else if (b && c && g) {
628
+ const bVal = YAML.stringify(b).trim();
629
+ const cVal = YAML.stringify(c).trim();
630
+ const gVal = YAML.stringify(g).trim();
631
+ const currChanged = bVal !== cVal;
632
+ const genChanged = bVal !== gVal;
633
+ if (currChanged && genChanged) {
634
+ if (cVal !== gVal) return 'CONFLICT';
635
+ merged.push(c);
636
+ } else if (currChanged) {
637
+ merged.push(c);
638
+ } else if (genChanged) {
639
+ merged.push(g);
640
+ } else {
641
+ merged.push(c);
642
+ }
643
+ }
644
+ }
645
+
646
+ // Check if order and content match current
647
+ if (merged.length === currentSeq.items.length) {
648
+ let same = true;
649
+ for (let i = 0; i < merged.length; i++) {
650
+ if (YAML.stringify(merged[i]).trim() !== YAML.stringify(currentSeq.items[i]).trim()) {
651
+ same = false;
652
+ break;
653
+ }
654
+ }
655
+ if (same) return 'CLEAN';
656
+ }
657
+
658
+ // Need to reconstruct the sequence value
659
+ const value = merged.map((item) => {
660
+ // Convert YAML node to plain JS value
661
+ try {
662
+ return YAML.parse(YAML.stringify(item));
663
+ } catch {
664
+ return null;
665
+ }
666
+ });
667
+
668
+ return { value, node: currentSeq };
669
+ }