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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|