release-skill 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,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
+ }