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,641 @@
1
+ /**
2
+ * JSON/JSONC format-preserving three-way merge.
3
+ *
4
+ * Locates target values by JSON Pointer in the raw byte stream and performs
5
+ * minimal byte-range replacements. Comments, key order, and untouched
6
+ * whitespace are preserved byte-for-byte from the current side.
7
+ *
8
+ * For arrays with an identity key, elements are matched by that key and
9
+ * merged per-element. Without an identity key, two-side array modifications
10
+ * are always CONFLICT (fail closed).
11
+ *
12
+ * No full-file parse or stringify is ever performed.
13
+ *
14
+ * @module artifacts/merge/json
15
+ */
16
+
17
+ import { mergeText } from './text.mjs';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // JSONC-aware byte scanner
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Strip // and /* comments from a JSONC buffer, returning a clean JSON
25
+ * buffer suitable for JSON.parse. This is only used for structural
26
+ * analysis — the original bytes are used for all output.
27
+ *
28
+ * @param {Buffer} buf
29
+ * @returns {string}
30
+ */
31
+ function stripJsoncComments(buf) {
32
+ const text = buf.toString('utf8');
33
+ const out = [];
34
+ let i = 0;
35
+ while (i < text.length) {
36
+ if (text[i] === '/' && i + 1 < text.length) {
37
+ if (text[i + 1] === '/') {
38
+ // Line comment — skip to end of line
39
+ while (i < text.length && text[i] !== '\n') i++;
40
+ continue;
41
+ }
42
+ if (text[i + 1] === '*') {
43
+ // Block comment — skip to */
44
+ i += 2;
45
+ while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
46
+ i += 2; // skip */
47
+ continue;
48
+ }
49
+ }
50
+ if (text[i] === '"') {
51
+ // String literal — copy verbatim including escapes
52
+ out.push(text[i]);
53
+ i++;
54
+ while (i < text.length) {
55
+ if (text[i] === '\\') {
56
+ out.push(text[i], text[i + 1] ?? '');
57
+ i += 2;
58
+ continue;
59
+ }
60
+ out.push(text[i]);
61
+ if (text[i] === '"') { i++; break; }
62
+ i++;
63
+ }
64
+ continue;
65
+ }
66
+ out.push(text[i]);
67
+ i++;
68
+ }
69
+ return out.join('');
70
+ }
71
+
72
+ /**
73
+ * Try to parse JSONC content, returning the parsed value or null on failure.
74
+ *
75
+ * @param {Buffer} buf
76
+ * @returns {*|null}
77
+ */
78
+ function tryParseJsonc(buf) {
79
+ try {
80
+ return JSON.parse(stripJsoncComments(buf));
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // JSONC-aware byte-range scanner
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Skip whitespace and // or /* comments from `src` starting at offset `i`.
92
+ * Returns the new offset past whitespace and comments.
93
+ *
94
+ * @param {string} src
95
+ * @param {number} i
96
+ * @returns {number}
97
+ */
98
+ function skipWsAndComments(src, i) {
99
+ while (i < src.length) {
100
+ // Skip whitespace
101
+ if (/\s/.test(src[i])) { i++; continue; }
102
+ // Skip // line comment
103
+ if (src[i] === '/' && i + 1 < src.length && src[i + 1] === '/') {
104
+ while (i < src.length && src[i] !== '\n') i++;
105
+ continue;
106
+ }
107
+ // Skip /* block comment */
108
+ if (src[i] === '/' && i + 1 < src.length && src[i + 1] === '*') {
109
+ i += 2;
110
+ while (i + 1 < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
111
+ i += 2;
112
+ continue;
113
+ }
114
+ break;
115
+ }
116
+ return i;
117
+ }
118
+
119
+ /**
120
+ * Decode a single JSON Pointer component per RFC 6901.
121
+ * ~1 → / and ~0 → ~
122
+ *
123
+ * @param {string} part
124
+ * @returns {string}
125
+ */
126
+ function decodePointerPart(part) {
127
+ return part.replace(/~1/g, '/').replace(/~0/g, '~');
128
+ }
129
+
130
+ /**
131
+ * Find the byte range [start, end) of the value at a given JSON Pointer
132
+ * path in the raw source.
133
+ *
134
+ * Uses a comment-aware brace/bracket-depth scanner that tracks strings and
135
+ * escapes. Returns null if the path is not found.
136
+ *
137
+ * @param {string} src - Source text (utf8 string)
138
+ * @param {string} pointer - JSON Pointer (e.g. "/scripts/build")
139
+ * @returns {{ start: number, end: number }|null}
140
+ */
141
+ function locatePointer(src, pointer) {
142
+ if (pointer === '') {
143
+ return findValueRange(src, 0);
144
+ }
145
+
146
+ const parts = pointer.split('/').filter(Boolean);
147
+ let pos = 0;
148
+
149
+ for (let depth = 0; depth < parts.length; depth++) {
150
+ const part = decodePointerPart(parts[depth]);
151
+ const container = findValueRange(src, pos);
152
+ if (!container) return null;
153
+ const containerSrc = src.substring(container.start, container.end);
154
+
155
+ if (containerSrc[0] === '{') {
156
+ const memberRange = findObjectMember(src, container.start, part);
157
+ if (!memberRange) return null;
158
+ if (depth === parts.length - 1) {
159
+ return memberRange.valueRange;
160
+ }
161
+ pos = memberRange.valueRange.start;
162
+ } else if (containerSrc[0] === '[') {
163
+ const index = parseInt(part, 10);
164
+ if (Number.isNaN(index)) return null;
165
+ const elementRange = findArrayElement(src, container.start, index);
166
+ if (!elementRange) return null;
167
+ if (depth === parts.length - 1) {
168
+ return elementRange;
169
+ }
170
+ pos = elementRange.start;
171
+ } else {
172
+ return null;
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Find the byte range of the JSON value starting at or after `offset`.
180
+ * Comment-aware: skips // and /* comments as whitespace.
181
+ *
182
+ * @param {string} src
183
+ * @param {number} offset
184
+ * @returns {{ start: number, end: number }|null}
185
+ */
186
+ function findValueRange(src, offset) {
187
+ let i = skipWsAndComments(src, offset);
188
+ if (i >= src.length) return null;
189
+
190
+ const start = i;
191
+ const ch = src[i];
192
+
193
+ if (ch === '"') {
194
+ // String — find closing quote
195
+ i++;
196
+ while (i < src.length) {
197
+ if (src[i] === '\\') { i += 2; continue; }
198
+ if (src[i] === '"') { i++; return { start, end: i }; }
199
+ i++;
200
+ }
201
+ return null;
202
+ }
203
+
204
+ if (ch === '{' || ch === '[') {
205
+ // Object or array — track depth, comment-aware
206
+ const open = ch;
207
+ const close = ch === '{' ? '}' : ']';
208
+ let depth = 0;
209
+ let inString = false;
210
+ while (i < src.length) {
211
+ if (inString) {
212
+ if (src[i] === '\\') { i += 2; continue; }
213
+ if (src[i] === '"') inString = false;
214
+ i++;
215
+ continue;
216
+ }
217
+ // Skip comments when not inside a string
218
+ if (src[i] === '/' && i + 1 < src.length && src[i + 1] === '/') {
219
+ while (i < src.length && src[i] !== '\n') i++;
220
+ continue;
221
+ }
222
+ if (src[i] === '/' && i + 1 < src.length && src[i + 1] === '*') {
223
+ i += 2;
224
+ while (i + 1 < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
225
+ i += 2;
226
+ continue;
227
+ }
228
+ if (src[i] === '"') { inString = true; i++; continue; }
229
+ if (src[i] === open) depth++;
230
+ if (src[i] === close) { depth--; if (depth === 0) { i++; return { start, end: i }; } }
231
+ i++;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ // Primitive (number, boolean, null) — scan to next delimiter
237
+ while (i < src.length && /[\w.\-+eE]/.test(src[i])) i++;
238
+ return i > start ? { start, end: i } : null;
239
+ }
240
+
241
+ /**
242
+ * Find a named member value range within an object starting at `objStart`.
243
+ * Comment-aware: skips // and /* comments between tokens.
244
+ *
245
+ * @param {string} src
246
+ * @param {number} objStart - Byte offset of the opening '{'
247
+ * @param {string} key - Member key to find
248
+ * @returns {{ keyRange: { start: number, end: number }, valueRange: { start: number, end: number } }|null}
249
+ */
250
+ function findObjectMember(src, objStart, key) {
251
+ let i = objStart + 1; // skip '{'
252
+
253
+ while (i < src.length) {
254
+ // Skip whitespace and comments
255
+ i = skipWsAndComments(src, i);
256
+ if (i >= src.length || src[i] === '}') return null;
257
+
258
+ // Expect a key (string)
259
+ if (src[i] !== '"') return null;
260
+ const keyStart = i;
261
+ i++;
262
+ while (i < src.length) {
263
+ if (src[i] === '\\') { i += 2; continue; }
264
+ if (src[i] === '"') break;
265
+ i++;
266
+ }
267
+ const keyEnd = i + 1; // include closing quote
268
+ const memberKey = src.substring(keyStart + 1, keyEnd - 1);
269
+
270
+ i = keyEnd;
271
+ // Skip whitespace, comments, and colon
272
+ i = skipWsAndComments(src, i);
273
+ if (src[i] !== ':') return null;
274
+ i++; // skip ':'
275
+
276
+ // Find value
277
+ const valueRange = findValueRange(src, i);
278
+ if (!valueRange) return null;
279
+
280
+ if (memberKey === key) {
281
+ return { keyRange: { start: keyStart, end: keyEnd }, valueRange };
282
+ }
283
+
284
+ i = valueRange.end;
285
+ // Skip comma
286
+ i = skipWsAndComments(src, i);
287
+ if (src[i] === ',') i++;
288
+ }
289
+
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * Find the byte range of the nth element in an array starting at `arrStart`.
295
+ * Comment-aware.
296
+ *
297
+ * @param {string} src
298
+ * @param {number} arrStart - Byte offset of the opening '['
299
+ * @param {number} index - Zero-based element index
300
+ * @returns {{ start: number, end: number }|null}
301
+ */
302
+ function findArrayElement(src, arrStart, index) {
303
+ let i = arrStart + 1; // skip '['
304
+ let count = 0;
305
+
306
+ while (i < src.length) {
307
+ i = skipWsAndComments(src, i);
308
+ if (i >= src.length || src[i] === ']') return null;
309
+
310
+ const range = findValueRange(src, i);
311
+ if (!range) return null;
312
+
313
+ if (count === index) return range;
314
+
315
+ i = range.end;
316
+ count++;
317
+ // Skip comma
318
+ i = skipWsAndComments(src, i);
319
+ if (src[i] === ',') i++;
320
+ }
321
+
322
+ return null;
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Identity-keyed array merge
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Merge an array at a given pointer using an identity key to match elements.
331
+ *
332
+ * Returns local element edits, CLEAN, or CONFLICT. Structural additions and
333
+ * deletions fail closed until comma-safe insertion/deletion is implemented.
334
+ *
335
+ * @param {string} src - Current side source text
336
+ * @param {string} baseSrc - Base source text
337
+ * @param {string} genSrc - Generated source text
338
+ * @param {string} pointer - JSON Pointer to the array
339
+ * @param {string} identityKey - Key name used to match elements
340
+ * @returns {{ edits: Array<{range:{start:number,end:number},replacement:string}> }|'CLEAN'|'CONFLICT'}
341
+ */
342
+ function mergeIdentityKeyedArray(src, baseSrc, genSrc, pointer, identityKey) {
343
+ const currentArr = extractArrayAtPath(src, pointer);
344
+ const baseArr = extractArrayAtPath(baseSrc, pointer);
345
+ const genArr = extractArrayAtPath(genSrc, pointer);
346
+
347
+ if (!currentArr || !baseArr || !genArr) return 'CONFLICT';
348
+
349
+ const indexByKey = (array) => {
350
+ const result = new Map();
351
+ for (let index = 0; index < array.length; index += 1) {
352
+ const element = array[index];
353
+ if (!element || typeof element !== 'object' || !(identityKey in element)) return null;
354
+ const key = element[identityKey];
355
+ if (result.has(key)) return null;
356
+ result.set(key, { el: element, index });
357
+ }
358
+ return result;
359
+ };
360
+ const baseByKey = indexByKey(baseArr);
361
+ const currentByKey = indexByKey(currentArr);
362
+ const genByKey = indexByKey(genArr);
363
+ if (!baseByKey || !currentByKey || !genByKey) return 'CONFLICT';
364
+
365
+ const baseArrayRange = locatePointer(baseSrc, pointer);
366
+ const currentArrayRange = locatePointer(src, pointer);
367
+ const genArrayRange = locatePointer(genSrc, pointer);
368
+ if (!baseArrayRange || !currentArrayRange || !genArrayRange) return 'CONFLICT';
369
+
370
+ const allKeys = new Set([...baseByKey.keys(), ...currentByKey.keys(), ...genByKey.keys()]);
371
+ const edits = [];
372
+
373
+ for (const key of allKeys) {
374
+ const base = baseByKey.get(key);
375
+ const curr = currentByKey.get(key);
376
+ const gen = genByKey.get(key);
377
+
378
+ if (!base && curr && gen) {
379
+ // Both added same key — check if identical
380
+ if (JSON.stringify(curr.el) !== JSON.stringify(gen.el)) return 'CONFLICT';
381
+ // Current already contains the identical addition.
382
+ } else if (!base && curr && !gen) {
383
+ // Preserve a current-only (human) addition.
384
+ } else if (!base && !curr && gen) {
385
+ return 'CONFLICT'; // comma-safe insertion is not implemented
386
+ } else if (base && curr && !gen) {
387
+ if (JSON.stringify(base.el) !== JSON.stringify(curr.el)) return 'CONFLICT';
388
+ return 'CONFLICT'; // comma-safe deletion is not implemented
389
+ } else if (base && !curr && gen) {
390
+ if (JSON.stringify(base.el) !== JSON.stringify(gen.el)) return 'CONFLICT';
391
+ // Human deleted, producer unchanged → accept delete (skip)
392
+ } else if (base && !curr && !gen) {
393
+ // Both deleted — skip
394
+ } else if (base && curr && gen) {
395
+ const currChanged = JSON.stringify(base.el) !== JSON.stringify(curr.el);
396
+ const genChanged = JSON.stringify(base.el) !== JSON.stringify(gen.el);
397
+ if (currChanged && genChanged) {
398
+ if (JSON.stringify(curr.el) !== JSON.stringify(gen.el)) return 'CONFLICT';
399
+ } else if (!currChanged && genChanged) {
400
+ const baseRange = findArrayElement(baseSrc, baseArrayRange.start, base.index);
401
+ const currentRange = findArrayElement(src, currentArrayRange.start, curr.index);
402
+ const generatedRange = findArrayElement(genSrc, genArrayRange.start, gen.index);
403
+ if (!baseRange || !currentRange || !generatedRange) return 'CONFLICT';
404
+ const baseRaw = baseSrc.substring(baseRange.start, baseRange.end);
405
+ const currentRaw = src.substring(currentRange.start, currentRange.end);
406
+ // A formatting/comment-only human delta is still protected.
407
+ if (currentRaw !== baseRaw || rangeContainsComments(src, currentRange.start, currentRange.end)) {
408
+ return 'CONFLICT';
409
+ }
410
+ edits.push({
411
+ range: currentRange,
412
+ replacement: genSrc.substring(generatedRange.start, generatedRange.end),
413
+ });
414
+ }
415
+ }
416
+ }
417
+
418
+ return edits.length === 0 ? 'CLEAN' : { edits };
419
+ }
420
+
421
+ /**
422
+ * Extract an array value at a given pointer from source text.
423
+ *
424
+ * @param {string} src
425
+ * @param {string} pointer
426
+ * @returns {Array|null}
427
+ */
428
+ function extractArrayAtPath(src, pointer) {
429
+ const parsed = tryParseJsonc(Buffer.from(src, 'utf8'));
430
+ if (!parsed) return null;
431
+
432
+ const parts = pointer.split('/').filter(Boolean);
433
+ let value = parsed;
434
+ for (const part of parts) {
435
+ const decoded = decodePointerPart(part);
436
+ if (value == null || typeof value !== 'object') return null;
437
+ value = value[decoded];
438
+ }
439
+ return Array.isArray(value) ? value : null;
440
+ }
441
+
442
+ function valueAtPointer(root, pointer) {
443
+ let value = root;
444
+ for (const part of pointer.split('/').filter(Boolean)) {
445
+ const decoded = decodePointerPart(part);
446
+ if (value == null || typeof value !== 'object' || !(decoded in value)) return undefined;
447
+ value = value[decoded];
448
+ }
449
+ return value;
450
+ }
451
+
452
+ /**
453
+ * Check whether a byte range in source text contains JSONC comments.
454
+ *
455
+ * @param {string} src
456
+ * @param {number} start
457
+ * @param {number} end
458
+ * @returns {boolean}
459
+ */
460
+ function rangeContainsComments(src, start, end) {
461
+ let inString = false;
462
+ for (let i = start; i < end; i++) {
463
+ if (inString) {
464
+ if (src[i] === '\\') { i++; continue; }
465
+ if (src[i] === '"') inString = false;
466
+ continue;
467
+ }
468
+ if (src[i] === '"') { inString = true; continue; }
469
+ if (src[i] === '/' && i + 1 < end) {
470
+ if (src[i + 1] === '/' || src[i + 1] === '*') return true;
471
+ }
472
+ }
473
+ return false;
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Public API
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /**
481
+ * Merge two JSON/JSONC modifications of the same base content.
482
+ *
483
+ * Performs minimal byte-range replacements at the specified JSON Pointers.
484
+ * Comments, key order, and untouched whitespace are preserved from the
485
+ * current side.
486
+ *
487
+ * @param {object} options
488
+ * @param {Buffer} options.base - Base JSON/JSONC content.
489
+ * @param {Buffer} options.current - Current (human) JSON/JSONC content.
490
+ * @param {Buffer} options.generated - Generated (producer) JSON/JSONC content.
491
+ * @param {string[]} [options.pointers=[]] - JSON Pointers of values to merge.
492
+ * @param {Record<string,string>} [options.identityKeys={}]
493
+ * Map of JSON Pointer → identity key name for arrays at that pointer.
494
+ * @returns {{ status: 'MERGEABLE'|'CONFLICT'|'STRUCTURE_INVALID', bytes?: Buffer, conflicts: object[] }}
495
+ */
496
+ export function mergeJson({ base, current, generated, pointers = [], identityKeys = {} } = {}) {
497
+ // Null/missing inputs → CONFLICT
498
+ if (!base || !current || !generated) {
499
+ return Object.freeze({
500
+ status: 'CONFLICT',
501
+ bytes: undefined,
502
+ conflicts: Object.freeze([{ reason: 'missing input' }]),
503
+ });
504
+ }
505
+
506
+ // Validate all three are parseable JSON(C)
507
+ const baseParsed = tryParseJsonc(base);
508
+ const currentParsed = tryParseJsonc(current);
509
+ const generatedParsed = tryParseJsonc(generated);
510
+
511
+ if (baseParsed === null || currentParsed === null || generatedParsed === null) {
512
+ return Object.freeze({
513
+ status: 'STRUCTURE_INVALID',
514
+ bytes: undefined,
515
+ conflicts: Object.freeze([{ reason: 'malformed JSON/JSONC input' }]),
516
+ });
517
+ }
518
+
519
+ const src = current.toString('utf8');
520
+ const baseSrc = base.toString('utf8');
521
+ const genSrc = generated.toString('utf8');
522
+
523
+ // Collect edits as { range, replacement } sorted by position (descending for safe apply)
524
+ const edits = [];
525
+
526
+ for (const pointer of pointers) {
527
+ const idKey = identityKeys[pointer];
528
+
529
+ if (idKey) {
530
+ // Identity-keyed array merge
531
+ const result = mergeIdentityKeyedArray(src, baseSrc, genSrc, pointer, idKey);
532
+ if (result === 'CONFLICT') {
533
+ return Object.freeze({
534
+ status: 'CONFLICT',
535
+ bytes: undefined,
536
+ conflicts: Object.freeze([{ reason: `array conflict at ${pointer}`, pointer }]),
537
+ });
538
+ }
539
+ if (result === 'CLEAN') continue;
540
+ edits.push(...result.edits);
541
+ } else {
542
+ // Simple value merge: locate byte ranges in each side
543
+ const currentRange = locatePointer(src, pointer);
544
+ const baseRange = locatePointer(baseSrc, pointer);
545
+ const genRange = locatePointer(genSrc, pointer);
546
+
547
+ if (!currentRange || !baseRange || !genRange) {
548
+ return Object.freeze({
549
+ status: 'STRUCTURE_INVALID',
550
+ bytes: undefined,
551
+ conflicts: Object.freeze([{ reason: `pointer not found: ${pointer}`, pointer }]),
552
+ });
553
+ }
554
+
555
+ const currentValue = src.substring(currentRange.start, currentRange.end);
556
+ const baseValue = baseSrc.substring(baseRange.start, baseRange.end);
557
+ const genValue = genSrc.substring(genRange.start, genRange.end);
558
+
559
+ const currentChanged = currentValue !== baseValue;
560
+ const genChanged = genValue !== baseValue;
561
+
562
+ const baseSemantic = valueAtPointer(baseParsed, pointer);
563
+ const currentSemantic = valueAtPointer(currentParsed, pointer);
564
+ const generatedSemantic = valueAtPointer(generatedParsed, pointer);
565
+ if (Array.isArray(baseSemantic) && Array.isArray(currentSemantic) &&
566
+ Array.isArray(generatedSemantic) && currentChanged && genChanged) {
567
+ return Object.freeze({
568
+ status: 'CONFLICT',
569
+ bytes: undefined,
570
+ conflicts: Object.freeze([{
571
+ reason: `array modified on both sides without identity key at ${pointer}`,
572
+ pointer,
573
+ }]),
574
+ });
575
+ }
576
+
577
+ if (!currentChanged && !genChanged) {
578
+ continue; // No change on either side
579
+ }
580
+
581
+ if (currentChanged && genChanged) {
582
+ if (currentValue === genValue) {
583
+ // Same change — accept (current already has it)
584
+ continue;
585
+ }
586
+ // Divergent changes to a simple value — for non-array, try text merge
587
+ // of the raw value strings
588
+ const mergeResult = mergeText({
589
+ base: Buffer.from(baseValue, 'utf8'),
590
+ current: Buffer.from(currentValue, 'utf8'),
591
+ generated: Buffer.from(genValue, 'utf8'),
592
+ });
593
+ if (mergeResult.status === 'CONFLICT') {
594
+ return Object.freeze({
595
+ status: 'CONFLICT',
596
+ bytes: undefined,
597
+ conflicts: Object.freeze([{
598
+ reason: `divergent value at ${pointer}`,
599
+ pointer,
600
+ }]),
601
+ });
602
+ }
603
+ // Text merge succeeded — but we already have the current value in place
604
+ // If mergeResult differs from current, apply the edit
605
+ const mergedValue = mergeResult.bytes.toString('utf8');
606
+ if (mergedValue !== currentValue) {
607
+ edits.push({ range: currentRange, replacement: mergedValue });
608
+ }
609
+ } else if (!currentChanged && genChanged) {
610
+ // Only generated changed — apply generated value
611
+ edits.push({ range: currentRange, replacement: genValue });
612
+ }
613
+ // If only current changed — keep current (no edit needed)
614
+ }
615
+ }
616
+
617
+ if (edits.length === 0) {
618
+ // No changes to apply — return current bytes as-is
619
+ return Object.freeze({
620
+ status: 'MERGEABLE',
621
+ bytes: Buffer.from(current),
622
+ conflicts: Object.freeze([]),
623
+ });
624
+ }
625
+
626
+ // Apply edits in reverse order (highest offset first) to preserve positions
627
+ edits.sort((a, b) => b.range.start - a.range.start);
628
+
629
+ let result = src;
630
+ for (const edit of edits) {
631
+ result = result.substring(0, edit.range.start) +
632
+ edit.replacement +
633
+ result.substring(edit.range.end);
634
+ }
635
+
636
+ return Object.freeze({
637
+ status: 'MERGEABLE',
638
+ bytes: Buffer.from(result, 'utf8'),
639
+ conflicts: Object.freeze([]),
640
+ });
641
+ }