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,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
|
+
}
|