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,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way text merge for UTF-8 artifact content.
|
|
3
|
+
*
|
|
4
|
+
* Performs line-level diff3 on normalised (LF) content, preserving the
|
|
5
|
+
* original encoding (UTF-8 BOM detection) and trailing-newline state.
|
|
6
|
+
*
|
|
7
|
+
* Conflict regions are returned as structured data — no conflict markers
|
|
8
|
+
* (<<<<<<< / ======= / >>>>>>>) are ever written to the merged output.
|
|
9
|
+
*
|
|
10
|
+
* Non-UTF-8 input, mixed illegal line endings, or excessive line counts
|
|
11
|
+
* are treated as binary/structure conflicts.
|
|
12
|
+
*
|
|
13
|
+
* @module artifacts/merge/text
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Diff helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute the longest common subsequence table between two line arrays.
|
|
22
|
+
*
|
|
23
|
+
* Returns `null` when the cell count (m × n) exceeds `MAX_LCS_CELLS`,
|
|
24
|
+
* signalling that the merge should fail closed.
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} a
|
|
27
|
+
* @param {string[]} b
|
|
28
|
+
* @returns {number[][]|null} LCS dynamic-programming table, or null if too large.
|
|
29
|
+
*/
|
|
30
|
+
function lcsTable(a, b) {
|
|
31
|
+
const m = a.length;
|
|
32
|
+
const n = b.length;
|
|
33
|
+
if (m * n > MAX_LCS_CELLS) return null;
|
|
34
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
35
|
+
for (let i = 1; i <= m; i++) {
|
|
36
|
+
for (let j = 1; j <= n; j++) {
|
|
37
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
38
|
+
? dp[i - 1][j - 1] + 1
|
|
39
|
+
: Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return dp;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Back-trace the LCS table to produce a diff operations list.
|
|
47
|
+
*
|
|
48
|
+
* Operations:
|
|
49
|
+
* - `equal` — line is the same on both sides.
|
|
50
|
+
* - `delete` — line exists in `a` but not in `b`.
|
|
51
|
+
* - `insert` — line exists in `b` but not in `a`.
|
|
52
|
+
*
|
|
53
|
+
* @param {string[]} a
|
|
54
|
+
* @param {string[]} b
|
|
55
|
+
* @param {number[][]} dp
|
|
56
|
+
* @returns {Array<{op: string, aLine?: string, bLine?: string}>}
|
|
57
|
+
*/
|
|
58
|
+
function diffFromLcs(a, b, dp) {
|
|
59
|
+
const ops = [];
|
|
60
|
+
let i = a.length;
|
|
61
|
+
let j = b.length;
|
|
62
|
+
while (i > 0 || j > 0) {
|
|
63
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
64
|
+
ops.push({ op: 'equal', aLine: a[i - 1] });
|
|
65
|
+
i--; j--;
|
|
66
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
67
|
+
ops.push({ op: 'insert', bLine: b[j - 1] });
|
|
68
|
+
j--;
|
|
69
|
+
} else {
|
|
70
|
+
ops.push({ op: 'delete', aLine: a[i - 1] });
|
|
71
|
+
i--;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return ops.reverse();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compute a line-level diff between two string arrays.
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` when the LCS cell count exceeds the cap, signalling
|
|
81
|
+
* that the merge should fail closed with a complexity conflict.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} a - Source lines.
|
|
84
|
+
* @param {string[]} b - Target lines.
|
|
85
|
+
* @returns {Array<{op: string, line?: string, aLine?: string, bLine?: string}>|null}
|
|
86
|
+
*/
|
|
87
|
+
function diffLines(a, b) {
|
|
88
|
+
const dp = lcsTable(a, b);
|
|
89
|
+
if (!dp) return null;
|
|
90
|
+
const raw = diffFromLcs(a, b, dp);
|
|
91
|
+
return raw.map((e) => {
|
|
92
|
+
if (e.op === 'equal') return { op: 'equal', line: e.aLine };
|
|
93
|
+
if (e.op === 'delete') return { op: 'delete', line: e.aLine };
|
|
94
|
+
return { op: 'insert', line: e.bLine };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Maximum number of lines the merge will process before refusing.
|
|
100
|
+
* Prevents unbounded memory/CPU on adversarial inputs.
|
|
101
|
+
*/
|
|
102
|
+
const MAX_LINES = 200_000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Maximum number of cells (base.length × side.length) the LCS table
|
|
106
|
+
* will allocate. Prevents unbounded memory on large divergent inputs.
|
|
107
|
+
*
|
|
108
|
+
* At 4 bytes per cell, 4M cells ≈ 16 MB — a reasonable ceiling.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_LCS_CELLS = 4_000_000;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Encoding helpers
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Detect encoding features of a byte buffer.
|
|
118
|
+
*
|
|
119
|
+
* Returns the BOM prefix (if any) and whether the content has a trailing
|
|
120
|
+
* newline.
|
|
121
|
+
*
|
|
122
|
+
* @param {Buffer} bytes
|
|
123
|
+
* @returns {{ bom: Buffer|null, hasTrailingNewline: boolean }}
|
|
124
|
+
*/
|
|
125
|
+
function detectEncoding(bytes) {
|
|
126
|
+
let bom = null;
|
|
127
|
+
let start = 0;
|
|
128
|
+
if (bytes.length >= 3 &&
|
|
129
|
+
bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) {
|
|
130
|
+
bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
131
|
+
start = 3;
|
|
132
|
+
}
|
|
133
|
+
const hasTrailingNewline = bytes.length > start &&
|
|
134
|
+
bytes[bytes.length - 1] === 0x0A;
|
|
135
|
+
return { bom, hasTrailingNewline };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Normalise line endings to LF and split into lines.
|
|
140
|
+
*
|
|
141
|
+
* Strips the BOM prefix (if present) before splitting. The trailing empty
|
|
142
|
+
* string produced by a final LF is trimmed so that the lines array contains
|
|
143
|
+
* only content lines.
|
|
144
|
+
*
|
|
145
|
+
* @param {Buffer} bytes
|
|
146
|
+
* @returns {{ lines: string[], bom: Buffer|null, hasTrailingNewline: boolean }}
|
|
147
|
+
*/
|
|
148
|
+
function normaliseToLines(bytes) {
|
|
149
|
+
const { bom, hasTrailingNewline } = detectEncoding(bytes);
|
|
150
|
+
let text;
|
|
151
|
+
try {
|
|
152
|
+
// Keep the BOM code point so the explicit encoding state below remains
|
|
153
|
+
// authoritative; the default decoder behavior would already strip it.
|
|
154
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
155
|
+
text = decoder.decode(bytes);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Strip BOM from the decoded text
|
|
161
|
+
if (bom) text = text.slice(1);
|
|
162
|
+
|
|
163
|
+
// Strict LF contract: reject any CR presence (CRLF, bare CR) as structure conflict.
|
|
164
|
+
// Only bare LF is acceptable.
|
|
165
|
+
const hasCR = text.includes('\r');
|
|
166
|
+
if (hasCR) return null;
|
|
167
|
+
|
|
168
|
+
// Normalise to LF
|
|
169
|
+
const normalised = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
170
|
+
const allLines = normalised.split('\n');
|
|
171
|
+
|
|
172
|
+
// If text ends with LF, split produces trailing '' — trim it
|
|
173
|
+
const lines = hasTrailingNewline && allLines[allLines.length - 1] === ''
|
|
174
|
+
? allLines.slice(0, -1)
|
|
175
|
+
: allLines;
|
|
176
|
+
|
|
177
|
+
return { lines, bom, hasTrailingNewline };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Reconstruct bytes from merged lines.
|
|
182
|
+
*
|
|
183
|
+
* @param {string[]} lines - Merged content lines.
|
|
184
|
+
* @param {{ bom: Buffer|null, hasTrailingNewline: boolean }} encoding
|
|
185
|
+
* @returns {Buffer}
|
|
186
|
+
*/
|
|
187
|
+
function linesToBytes(lines, encoding) {
|
|
188
|
+
const content = lines.join('\n');
|
|
189
|
+
const utf8 = Buffer.from(content, 'utf8');
|
|
190
|
+
if (encoding.bom) {
|
|
191
|
+
return encoding.hasTrailingNewline
|
|
192
|
+
? Buffer.concat([encoding.bom, utf8, Buffer.from('\n')])
|
|
193
|
+
: Buffer.concat([encoding.bom, utf8]);
|
|
194
|
+
}
|
|
195
|
+
return encoding.hasTrailingNewline
|
|
196
|
+
? Buffer.concat([utf8, Buffer.from('\n')])
|
|
197
|
+
: utf8;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mergeBooleanState(base, current, generated) {
|
|
201
|
+
if (current === generated) return { conflict: false, value: current };
|
|
202
|
+
if (current === base) return { conflict: false, value: generated };
|
|
203
|
+
if (generated === base) return { conflict: false, value: current };
|
|
204
|
+
return { conflict: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Diff3 merge (base → current × base → generated)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Classify a diff operation for edit-region grouping.
|
|
213
|
+
*
|
|
214
|
+
* - 'equal' — synchronisation point (both diffs must match here).
|
|
215
|
+
* - 'edit' — a delete or insert that constitutes part of an edit region.
|
|
216
|
+
*/
|
|
217
|
+
function diffToHunks(base, other) {
|
|
218
|
+
const ops = diffLines(base, other);
|
|
219
|
+
if (!ops) return null;
|
|
220
|
+
const hunks = [];
|
|
221
|
+
let baseIndex = 0;
|
|
222
|
+
let active = null;
|
|
223
|
+
|
|
224
|
+
const ensureActive = () => {
|
|
225
|
+
if (!active) active = { start: baseIndex, end: baseIndex, replacement: [] };
|
|
226
|
+
return active;
|
|
227
|
+
};
|
|
228
|
+
const flush = () => {
|
|
229
|
+
if (active) hunks.push(Object.freeze(active));
|
|
230
|
+
active = null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
for (const op of ops) {
|
|
234
|
+
if (op.op === 'equal') {
|
|
235
|
+
flush();
|
|
236
|
+
baseIndex += 1;
|
|
237
|
+
} else if (op.op === 'delete') {
|
|
238
|
+
ensureActive().end += 1;
|
|
239
|
+
baseIndex += 1;
|
|
240
|
+
} else {
|
|
241
|
+
ensureActive().replacement.push(op.line);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
flush();
|
|
245
|
+
return hunks;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function applyHunksToRegion(base, start, end, hunks) {
|
|
249
|
+
const output = [];
|
|
250
|
+
let cursor = start;
|
|
251
|
+
for (const hunk of hunks) {
|
|
252
|
+
output.push(...base.slice(cursor, hunk.start), ...hunk.replacement);
|
|
253
|
+
cursor = hunk.end;
|
|
254
|
+
}
|
|
255
|
+
output.push(...base.slice(cursor, end));
|
|
256
|
+
return output;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hunksOverlapRegion(hunk, start, end) {
|
|
260
|
+
if (start === end) return hunk.start === start;
|
|
261
|
+
if (hunk.start === hunk.end) return hunk.start >= start && hunk.start < end;
|
|
262
|
+
return hunk.start < end && hunk.end > start;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Three-way line merge.
|
|
267
|
+
*
|
|
268
|
+
* Computes diffs `base→current` and `base→generated` and walks them in
|
|
269
|
+
* lock-step. Consecutive non-equal operations on each side are grouped
|
|
270
|
+
* into edit regions. Each region is then compared across the two sides:
|
|
271
|
+
*
|
|
272
|
+
* - Both sides produce the same result → accept once.
|
|
273
|
+
* - Only one side changed → accept that side.
|
|
274
|
+
* - Both sides changed differently → CONFLICT.
|
|
275
|
+
*
|
|
276
|
+
* @param {string[]} base
|
|
277
|
+
* @param {string[]} current
|
|
278
|
+
* @param {string[]} generated
|
|
279
|
+
* @returns {{ merged: string[], conflicts: object[], humanHunks: string[] }}
|
|
280
|
+
*/
|
|
281
|
+
function diff3Merge(base, current, generated) {
|
|
282
|
+
const rawCurrentHunks = diffToHunks(base, current);
|
|
283
|
+
const rawGeneratedHunks = diffToHunks(base, generated);
|
|
284
|
+
if (!rawCurrentHunks || !rawGeneratedHunks) {
|
|
285
|
+
return { merged: null, conflicts: [{ reason: 'LCS complexity exceeded — cell count too large' }], humanHunks: [] };
|
|
286
|
+
}
|
|
287
|
+
const currentHunks = rawCurrentHunks.map((hunk) => ({ ...hunk, side: 'current' }));
|
|
288
|
+
const generatedHunks = rawGeneratedHunks.map((hunk) => ({ ...hunk, side: 'generated' }));
|
|
289
|
+
const pending = [...currentHunks, ...generatedHunks].sort((a, b) =>
|
|
290
|
+
a.start - b.start || a.end - b.end || a.side.localeCompare(b.side));
|
|
291
|
+
const merged = [];
|
|
292
|
+
const conflicts = [];
|
|
293
|
+
const humanHunks = [];
|
|
294
|
+
let cursor = 0;
|
|
295
|
+
|
|
296
|
+
while (pending.length > 0) {
|
|
297
|
+
const group = [pending.shift()];
|
|
298
|
+
let start = group[0].start;
|
|
299
|
+
let end = group[0].end;
|
|
300
|
+
let added = true;
|
|
301
|
+
while (added) {
|
|
302
|
+
added = false;
|
|
303
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
304
|
+
if (!hunksOverlapRegion(pending[index], start, end)) continue;
|
|
305
|
+
const [hunk] = pending.splice(index, 1);
|
|
306
|
+
group.push(hunk);
|
|
307
|
+
start = Math.min(start, hunk.start);
|
|
308
|
+
end = Math.max(end, hunk.end);
|
|
309
|
+
added = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
merged.push(...base.slice(cursor, start));
|
|
315
|
+
const cHunks = group.filter((h) => h.side === 'current').sort((a, b) => a.start - b.start);
|
|
316
|
+
const gHunks = group.filter((h) => h.side === 'generated').sort((a, b) => a.start - b.start);
|
|
317
|
+
const currentResult = applyHunksToRegion(base, start, end, cHunks);
|
|
318
|
+
const generatedResult = applyHunksToRegion(base, start, end, gHunks);
|
|
319
|
+
const same = currentResult.length === generatedResult.length &&
|
|
320
|
+
currentResult.every((line, index) => line === generatedResult[index]);
|
|
321
|
+
|
|
322
|
+
if (cHunks.length === 0) {
|
|
323
|
+
merged.push(...generatedResult);
|
|
324
|
+
} else if (gHunks.length === 0) {
|
|
325
|
+
merged.push(...currentResult);
|
|
326
|
+
humanHunks.push(currentResult.join('\n'));
|
|
327
|
+
} else if (same) {
|
|
328
|
+
merged.push(...currentResult);
|
|
329
|
+
} else {
|
|
330
|
+
conflicts.push({ currentLines: currentResult, generatedLines: generatedResult });
|
|
331
|
+
}
|
|
332
|
+
cursor = end;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
merged.push(...base.slice(cursor));
|
|
336
|
+
|
|
337
|
+
return { merged, conflicts, humanHunks };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Public API
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Merge two textual modifications of the same base content.
|
|
346
|
+
*
|
|
347
|
+
* Input bytes are expected to be valid UTF-8. Line endings are normalised
|
|
348
|
+
* to LF before diffing; the original encoding (BOM, trailing-newline state)
|
|
349
|
+
* is preserved in the output.
|
|
350
|
+
*
|
|
351
|
+
* Conflict regions are returned as structured `conflicts` entries — no
|
|
352
|
+
* `<<<<<<<` / `=======` / `>>>>>>>` markers appear in `bytes`.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} options
|
|
355
|
+
* @param {Buffer} options.base - Base (accepted) content.
|
|
356
|
+
* @param {Buffer} options.current - Current (human) content.
|
|
357
|
+
* @param {Buffer} options.generated - Generated (producer) content.
|
|
358
|
+
* @returns {{ status: 'MERGEABLE'|'CONFLICT', bytes?: Buffer, conflicts: object[], preservedHumanHunks: string[] }}
|
|
359
|
+
*/
|
|
360
|
+
export function mergeText({ base, current, generated }) {
|
|
361
|
+
// Null/undefined inputs → structure conflict
|
|
362
|
+
if (!base || !current || !generated) {
|
|
363
|
+
return Object.freeze({
|
|
364
|
+
status: 'CONFLICT',
|
|
365
|
+
conflicts: [{ reason: 'missing input' }],
|
|
366
|
+
preservedHumanHunks: [],
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Validate UTF-8
|
|
371
|
+
const baseN = normaliseToLines(base);
|
|
372
|
+
const currentN = normaliseToLines(current);
|
|
373
|
+
const generatedN = normaliseToLines(generated);
|
|
374
|
+
|
|
375
|
+
if (!baseN || !currentN || !generatedN) {
|
|
376
|
+
return Object.freeze({
|
|
377
|
+
status: 'CONFLICT',
|
|
378
|
+
conflicts: [{ reason: 'non-UTF-8 or CR/CRLF line endings (strict LF contract)' }],
|
|
379
|
+
preservedHumanHunks: [],
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Line-count guard
|
|
384
|
+
if (baseN.lines.length > MAX_LINES ||
|
|
385
|
+
currentN.lines.length > MAX_LINES ||
|
|
386
|
+
generatedN.lines.length > MAX_LINES) {
|
|
387
|
+
return Object.freeze({
|
|
388
|
+
status: 'CONFLICT',
|
|
389
|
+
conflicts: [{ reason: 'excessive line count' }],
|
|
390
|
+
preservedHumanHunks: [],
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const bomState = mergeBooleanState(Boolean(baseN.bom), Boolean(currentN.bom), Boolean(generatedN.bom));
|
|
395
|
+
const newlineState = mergeBooleanState(
|
|
396
|
+
baseN.hasTrailingNewline,
|
|
397
|
+
currentN.hasTrailingNewline,
|
|
398
|
+
generatedN.hasTrailingNewline,
|
|
399
|
+
);
|
|
400
|
+
if (bomState.conflict || newlineState.conflict) {
|
|
401
|
+
return Object.freeze({
|
|
402
|
+
status: 'CONFLICT',
|
|
403
|
+
conflicts: Object.freeze([Object.freeze({ reason: 'encoding state changed divergently' })]),
|
|
404
|
+
preservedHumanHunks: Object.freeze([]),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Diff3 merge
|
|
409
|
+
const { merged, conflicts, humanHunks } = diff3Merge(
|
|
410
|
+
baseN.lines, currentN.lines, generatedN.lines,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
if (merged === null || conflicts.length > 0) {
|
|
414
|
+
return Object.freeze({
|
|
415
|
+
status: 'CONFLICT',
|
|
416
|
+
conflicts: Object.freeze(conflicts.map((c) => Object.freeze(c))),
|
|
417
|
+
preservedHumanHunks: Object.freeze([]),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const bytes = linesToBytes(merged, {
|
|
422
|
+
bom: bomState.value ? Buffer.from([0xEF, 0xBB, 0xBF]) : null,
|
|
423
|
+
hasTrailingNewline: newlineState.value,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
status: 'MERGEABLE',
|
|
428
|
+
bytes,
|
|
429
|
+
conflicts: Object.freeze([]),
|
|
430
|
+
preservedHumanHunks: Object.freeze(humanHunks),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way tree (directory) merge.
|
|
3
|
+
*
|
|
4
|
+
* Computes the path union of base/current/generated entries and merges
|
|
5
|
+
* each path individually via the entry-level dispatcher. Detects
|
|
6
|
+
* undeclared renames (producer deletes + adds without a renameMap entry)
|
|
7
|
+
* and reports them as CONFLICT.
|
|
8
|
+
*
|
|
9
|
+
* The output manifest is recomputed via `digestEntryManifest` so it is
|
|
10
|
+
* always deterministic and content-addressed.
|
|
11
|
+
*
|
|
12
|
+
* @module artifacts/merge/tree
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { digestEntryManifest } from '../entry.mjs';
|
|
16
|
+
import { mergeEntry } from './entry-merge.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute the set-union of all paths across three entry maps.
|
|
20
|
+
*
|
|
21
|
+
* @param {Map<string,object>} baseE
|
|
22
|
+
* @param {Map<string,object>} currentE
|
|
23
|
+
* @param {Map<string,object>} generatedE
|
|
24
|
+
* @returns {string[]} Sorted unique paths.
|
|
25
|
+
*/
|
|
26
|
+
function pathUnion(baseE, currentE, generatedE) {
|
|
27
|
+
const set = new Set([...baseE.keys(), ...currentE.keys(), ...generatedE.keys()]);
|
|
28
|
+
return [...set].sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract a path→entry map from a tree entry's `entries` array.
|
|
33
|
+
*
|
|
34
|
+
* Absent entries are represented as `{ kind: 'absent' }`.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} treeEntry - A tree entry (kind: 'tree') or absent.
|
|
37
|
+
* @returns {Map<string,object>}
|
|
38
|
+
*/
|
|
39
|
+
function entryMap(treeEntry) {
|
|
40
|
+
if (!treeEntry || treeEntry.kind === 'absent' || !treeEntry.entries) {
|
|
41
|
+
return new Map();
|
|
42
|
+
}
|
|
43
|
+
return new Map(treeEntry.entries.map((e) => [e.path, e]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merge two modifications of the same base tree.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} options
|
|
50
|
+
* @param {object} options.base - Base tree entry.
|
|
51
|
+
* @param {object} options.current - Current tree entry.
|
|
52
|
+
* @param {object} options.generated - Generated tree entry.
|
|
53
|
+
* @param {Record<string,string>} [options.renameMap] - Declared renames (oldPath → newPath).
|
|
54
|
+
* @param {Record<string,string>} [options.drivers] - Per-path merge driver (path → 'text'|'binary').
|
|
55
|
+
* @returns {{ status: 'MERGEABLE'|'CONFLICT', candidate?: object, conflicts?: object[] }}
|
|
56
|
+
*/
|
|
57
|
+
export function mergeTree({ base, current, generated, renameMap, drivers } = {}) {
|
|
58
|
+
const baseE = entryMap(base);
|
|
59
|
+
const currentE = entryMap(current);
|
|
60
|
+
const generatedE = entryMap(generated);
|
|
61
|
+
const allPaths = pathUnion(baseE, currentE, generatedE);
|
|
62
|
+
|
|
63
|
+
const candidateEntries = [];
|
|
64
|
+
const conflicts = [];
|
|
65
|
+
|
|
66
|
+
// Per-entry merge
|
|
67
|
+
for (const path of allPaths) {
|
|
68
|
+
const b = baseE.get(path) ?? Object.freeze({ kind: 'absent' });
|
|
69
|
+
const c = currentE.get(path) ?? Object.freeze({ kind: 'absent' });
|
|
70
|
+
const g = generatedE.get(path) ?? Object.freeze({ kind: 'absent' });
|
|
71
|
+
|
|
72
|
+
const pathDriver = drivers?.[path] ?? 'text';
|
|
73
|
+
const result = mergeEntry({ base: b, current: c, generated: g, driver: pathDriver });
|
|
74
|
+
|
|
75
|
+
if (result.status === 'CONFLICT') {
|
|
76
|
+
conflicts.push({ path, reason: 'entry conflict' });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cand = result.candidate;
|
|
81
|
+
if (!cand || cand.kind === 'absent') continue;
|
|
82
|
+
|
|
83
|
+
// Normalise: ensure path and type are set for manifest digest
|
|
84
|
+
const entry = {
|
|
85
|
+
path: cand.path ?? path,
|
|
86
|
+
type: cand.type ?? 'blob',
|
|
87
|
+
mode: cand.mode ?? '100644',
|
|
88
|
+
sha256: cand.sha256 ?? '',
|
|
89
|
+
size: cand.size ?? 0,
|
|
90
|
+
};
|
|
91
|
+
candidateEntries.push(Object.freeze(entry));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- File-directory prefix collision detection ---
|
|
95
|
+
//
|
|
96
|
+
// When one side has a file at path P and another side has entries under
|
|
97
|
+
// P/ (making P a directory), the merge result is structurally invalid.
|
|
98
|
+
// This catches the case where a blob entry and a path prefixed by that
|
|
99
|
+
// blob's path both appear in the merged candidate set.
|
|
100
|
+
const candidatePaths = candidateEntries.map((e) => e.path).sort();
|
|
101
|
+
for (let i = 0; i < candidatePaths.length - 1; i += 1) {
|
|
102
|
+
const current = candidatePaths[i];
|
|
103
|
+
const next = candidatePaths[i + 1];
|
|
104
|
+
if (next.startsWith(`${current}/`)) {
|
|
105
|
+
conflicts.push({
|
|
106
|
+
path: current,
|
|
107
|
+
reason: `file-directory conflict: '${current}' is a blob but '${next}' exists under it`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- NFC + case-fold path collision detection ---
|
|
113
|
+
//
|
|
114
|
+
// Paths that differ only by Unicode normalization form (NFC vs NFD) or
|
|
115
|
+
// case fold would collide on macOS (HFS+) and Windows (NTFS) filesystems.
|
|
116
|
+
// Fail closed: any two candidate paths that normalize to the same key
|
|
117
|
+
// after NFC + case-fold are a CONFLICT.
|
|
118
|
+
{
|
|
119
|
+
const seen = new Map(); // normalisedKey → first path that used it
|
|
120
|
+
for (const path of candidatePaths) {
|
|
121
|
+
// NFC normalize then lowercase for case-fold equivalence
|
|
122
|
+
const key = path.normalize('NFC').toLowerCase();
|
|
123
|
+
const existing = seen.get(key);
|
|
124
|
+
if (existing) {
|
|
125
|
+
conflicts.push({
|
|
126
|
+
path,
|
|
127
|
+
reason: `NFC+case-fold path collision: '${path}' collides with '${existing}' after normalization`,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
seen.set(key, path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Undeclared-rename detection ---
|
|
136
|
+
//
|
|
137
|
+
// When the producer deleted paths from base AND added new paths that
|
|
138
|
+
// are not in the base, an explicit renameMap entry is required for
|
|
139
|
+
// every such pair. Otherwise the disappearance is ambiguous and the
|
|
140
|
+
// merge is a CONFLICT.
|
|
141
|
+
if (allPaths.length > 0) {
|
|
142
|
+
const reverseRenameMap = renameMap
|
|
143
|
+
? new Map(Object.entries(renameMap).map(([k, v]) => [v, k]))
|
|
144
|
+
: new Map();
|
|
145
|
+
|
|
146
|
+
// Paths that were in the base and deleted by the producer
|
|
147
|
+
const producerDeletions = [];
|
|
148
|
+
// Paths that are new in the generated (not in base or current)
|
|
149
|
+
const producerAdditions = [];
|
|
150
|
+
|
|
151
|
+
for (const path of allPaths) {
|
|
152
|
+
const inBase = baseE.has(path);
|
|
153
|
+
const inGen = generatedE.has(path);
|
|
154
|
+
|
|
155
|
+
if (inBase && !inGen) {
|
|
156
|
+
// Producer removed this path
|
|
157
|
+
if (!(renameMap && renameMap[path])) {
|
|
158
|
+
producerDeletions.push(path);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!inBase && inGen) {
|
|
162
|
+
// Producer added this path — check if it's a declared rename target
|
|
163
|
+
if (!reverseRenameMap.has(path)) {
|
|
164
|
+
producerAdditions.push(path);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Undeclared renames: if both sides have orphans, it's ambiguous
|
|
170
|
+
if (producerDeletions.length > 0 && producerAdditions.length > 0) {
|
|
171
|
+
for (const oldPath of producerDeletions) {
|
|
172
|
+
conflicts.push({
|
|
173
|
+
path: oldPath,
|
|
174
|
+
reason: 'undeclared rename: producer removed and added paths without renameMap',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Return conflicts if any
|
|
181
|
+
if (conflicts.length > 0) {
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
status: 'CONFLICT',
|
|
184
|
+
candidate: undefined,
|
|
185
|
+
conflicts: Object.freeze(conflicts.map((c) => Object.freeze(c))),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Build candidate tree
|
|
190
|
+
const manifestDigest = digestEntryManifest(candidateEntries);
|
|
191
|
+
const candidate = Object.freeze({
|
|
192
|
+
kind: 'tree',
|
|
193
|
+
entries: Object.freeze(candidateEntries),
|
|
194
|
+
manifestDigest,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return Object.freeze({
|
|
198
|
+
status: 'MERGEABLE',
|
|
199
|
+
candidate,
|
|
200
|
+
conflicts: Object.freeze([]),
|
|
201
|
+
});
|
|
202
|
+
}
|