happyskills 0.18.1 → 0.20.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - Add `--full-report` flag to `pull` command for AI agent semantic review (Layer 2 enablement) — when combined with `--json`, includes inline file content (`base_content`, `local_content`, `remote_content`, `merged_content`) for all changed files and action-typed `resolution_steps` (resolve_conflict_markers, review_json_suggestions, semantic_review, verify)
14
+ - Add `enrich_file_content()` and `build_resolution_steps()` to merge report module — deterministic resolution steps guide the consuming LLM through conflict resolution, JSON suggestion review, cross-file semantic consistency checks, and verification
15
+
16
+ ## [0.19.0] - 2026-03-29
17
+
18
+ ### Added
19
+ - Add three-way auto-merge to `pull` — when both local and remote have changes and no `--theirs`/`--ours` flag is set, `pull` now auto-merges using `node-diff3` for text files, structured JSON merge for `skill.json` (always valid JSON, no conflict markers), and section-aware merge for `CHANGELOG.md` (preserves remote history, prepends local sections)
20
+ - Add `conflict_files` tracking in lock file — `pull` writes an array of files with unresolved conflict markers to the lock entry; `status` shows `conflicts` state when present
21
+ - Add conflict marker validation (`validate_no_conflict_markers`) to `validate` and `publish` — scans all skill files for `<<<<<<< LOCAL` markers and blocks publishing with errors. `--force` does NOT bypass this check (it only bypasses divergence)
22
+ - Add `conflicts` status to `status` command for skills with unresolved merge conflicts
23
+
24
+ ### Changed
25
+ - `pull` without `--theirs`/`--ours` no longer exits with an error on `both_modified` files — it attempts auto-merge and reports any remaining conflicts with marker locations
26
+
10
27
  ## [0.18.1] - 2026-03-29
11
28
 
12
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -43,6 +43,7 @@
43
43
  "node": ">=22.0.0"
44
44
  },
45
45
  "dependencies": {
46
+ "node-diff3": "^3.2.0",
46
47
  "puffy-core": "^1.3.1",
47
48
  "semver": "^7.6.0"
48
49
  },
@@ -16,6 +16,7 @@ const { hash_directory } = require('../lock/integrity')
16
16
  const { validate_skill_md } = require('../validation/skill_md_rules')
17
17
  const { validate_skill_json } = require('../validation/skill_json_rules')
18
18
  const { validate_cross } = require('../validation/cross_rules')
19
+ const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
19
20
  const { create_spinner } = require('../ui/spinner')
20
21
  const { print_help, print_success, print_error, print_warn, print_hint, print_json, code } = require('../ui/output')
21
22
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
@@ -95,8 +96,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
95
96
  if (json_err) throw json_err
96
97
  const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, skill_type)
97
98
  if (cross_err) throw cross_err
99
+ const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
100
+ if (marker_err) throw marker_err
98
101
 
99
- const all_results = [...md_data.results, ...json_data.results, ...cross_results]
102
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
100
103
  const validation_errors = all_results.filter(r => r.severity === 'error')
101
104
  const validation_warnings = all_results.filter(r => r.severity === 'warning')
102
105
 
@@ -5,7 +5,10 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
5
5
  const repos_api = require('../api/repos')
6
6
  const { detect_status } = require('../merge/detector')
7
7
  const { classify_changes } = require('../merge/comparator')
8
- const { build_report } = require('../merge/report')
8
+ const { build_report, enrich_file_content, build_resolution_steps } = require('../merge/report')
9
+ const { three_way_merge } = require('../merge/text_merge')
10
+ const { merge_skill_json } = require('../merge/json_merge')
11
+ const { merge_changelog } = require('../merge/changelog_merge')
9
12
  const { hash_blob } = require('../utils/git_hash')
10
13
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
11
14
  const { write_lock, update_lock_skills } = require('../lock/writer')
@@ -30,14 +33,17 @@ Options:
30
33
  --force Discard all local changes, take remote entirely
31
34
  -g, --global Pull globally installed skill
32
35
  --json Output as JSON
36
+ --full-report Include inline file content and resolution steps in JSON output (for AI agent review)
33
37
 
34
38
  Examples:
35
39
  happyskills pull acme/deploy-aws
36
40
  happyskills pull acme/deploy-aws --theirs
37
- happyskills pull acme/deploy-aws --force`
41
+ happyskills pull acme/deploy-aws --json --full-report`
38
42
 
39
43
  // ─── Helpers ──────────────────────────────────────────────────────────────────
40
44
 
45
+ const decode_b64 = (file) => file ? Buffer.from(file.content, 'base64').toString('utf-8') : undefined
46
+
41
47
  const build_local_entries = (skill_dir) => catch_errors('Failed to build local entries', async () => {
42
48
  const entries = []
43
49
  const walk = async (dir, prefix) => {
@@ -100,6 +106,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
100
106
 
101
107
  const is_global = args.flags.global || false
102
108
  const strategy = args.flags.theirs ? 'theirs' : args.flags.ours ? 'ours' : args.flags.force ? 'force' : null
109
+ const full_report = !!(args.flags.json && args.flags['full-report'])
103
110
  const project_root = find_project_root()
104
111
 
105
112
  // 1. Read lock file
@@ -205,26 +212,86 @@ const run = (args) => catch_errors('Pull failed', async () => {
205
212
  const classified = classify_changes(base_files, local_files, remote_files)
206
213
  const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
207
214
 
208
- // If both_modified exists and no strategy → exit with report
215
+ // Apply changes
216
+ spinner.update('Applying changes...')
217
+ const base_file_map = new Map((base_clone.files || []).map(f => [f.path, f]))
218
+ const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
219
+
220
+ // Auto-merge both_modified files when no strategy is set
221
+ const conflict_files = []
222
+ const json_conflicts = []
209
223
  if (classified.both_modified.length > 0 && !strategy) {
210
- spinner.stop()
211
- if (args.flags.json) {
212
- print_json({ data: { status: 'conflicts', report } })
213
- } else {
214
- print_warn(`${report.summary.conflicted} file(s) modified on both sides:`)
215
- for (const f of classified.both_modified) {
216
- console.error(` - ${f.path}`)
224
+ spinner.update('Auto-merging...')
225
+ for (const entry of classified.both_modified) {
226
+ const report_file = report.files.find(f => f.path === entry.path)
227
+
228
+ // Delete-vs-modify cannot auto-merge, keep what exists
229
+ if (entry.local_sha === null || entry.remote_sha === null) {
230
+ conflict_files.push(entry.path)
231
+ if (report_file) report_file.conflict_written = true
232
+ if (entry.local_sha === null && entry.remote_sha !== null) {
233
+ // Local deleted, remote modified — write remote version
234
+ const [af_err] = await apply_remote_file(skill_dir, owner, repo, entry)
235
+ if (af_err) { spinner.fail(`Failed to apply ${entry.path}`); throw af_err[0] }
236
+ if (full_report && report_file) {
237
+ const rf = remote_file_map.get(entry.path)
238
+ enrich_file_content(report_file, {
239
+ base: decode_b64(base_file_map.get(entry.path)),
240
+ remote: decode_b64(rf)
241
+ })
242
+ }
243
+ } else if (full_report && report_file) {
244
+ // Local modified, remote deleted — capture what exists
245
+ const local_text = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8')
246
+ enrich_file_content(report_file, {
247
+ base: decode_b64(base_file_map.get(entry.path)),
248
+ local: local_text
249
+ })
250
+ }
251
+ continue
252
+ }
253
+
254
+ const base_file = base_file_map.get(entry.path)
255
+ const remote_file = remote_file_map.get(entry.path)
256
+ const base_text = base_file ? Buffer.from(base_file.content, 'base64').toString('utf-8') : ''
257
+ const local_text = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8')
258
+ const remote_text = remote_file ? Buffer.from(remote_file.content, 'base64').toString('utf-8') : ''
259
+
260
+ if (entry.path === 'skill.json') {
261
+ const result = merge_skill_json(
262
+ JSON.parse(base_text || '{}'),
263
+ JSON.parse(local_text || '{}'),
264
+ JSON.parse(remote_text || '{}')
265
+ )
266
+ const merged_text = JSON.stringify(result.merged, null, '\t') + '\n'
267
+ await fs.promises.writeFile(path.join(skill_dir, entry.path), merged_text)
268
+ if (result.conflicts.length > 0) json_conflicts.push(...result.conflicts)
269
+ if (report_file) {
270
+ report_file.merge_result = { type: 'json', conflicts: result.conflicts }
271
+ if (full_report) enrich_file_content(report_file, { base: base_text, local: local_text, remote: remote_text, merged: merged_text })
272
+ }
273
+ } else if (entry.path.toLowerCase() === 'changelog.md') {
274
+ const result = merge_changelog(base_text, local_text, remote_text)
275
+ await fs.promises.writeFile(path.join(skill_dir, entry.path), result.merged)
276
+ if (result.has_conflicts) conflict_files.push(entry.path)
277
+ if (report_file) {
278
+ report_file.conflict_written = result.has_conflicts
279
+ report_file.merge_result = { type: 'changelog', has_conflicts: result.has_conflicts, used_fallback: result.used_fallback }
280
+ if (full_report) enrich_file_content(report_file, { base: base_text, local: local_text, remote: remote_text, merged: result.merged })
281
+ }
282
+ } else {
283
+ const result = three_way_merge(base_text, local_text, remote_text)
284
+ await fs.promises.writeFile(path.join(skill_dir, entry.path), result.merged)
285
+ if (result.has_conflicts) conflict_files.push(entry.path)
286
+ if (report_file) {
287
+ report_file.conflict_written = result.has_conflicts
288
+ report_file.merge_result = { type: 'text', conflict_count: result.conflict_count, conflict_regions: result.conflict_regions }
289
+ if (full_report) enrich_file_content(report_file, { base: base_text, local: local_text, remote: remote_text, merged: result.merged })
290
+ }
217
291
  }
218
- console.error('')
219
- print_hint('Auto-merge not yet available. Use --theirs (take remote) or --ours (keep local) for all conflicting files, or manually merge and publish with --force.')
220
292
  }
221
- return process.exit(EXIT_CODES.ERROR)
222
293
  }
223
294
 
224
- // Apply changes
225
- spinner.update('Applying changes...')
226
- const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
227
-
228
295
  // Remote-only modified/added → write remote version
229
296
  for (const entry of [...classified.remote_only_modified, ...classified.remote_only_added]) {
230
297
  const remote_file = remote_file_map.get(entry.path)
@@ -233,19 +300,53 @@ const run = (args) => catch_errors('Pull failed', async () => {
233
300
  const [dir_err] = await ensure_dir(path.dirname(full))
234
301
  if (dir_err) { spinner.fail(`Failed to create dir for ${entry.path}`); throw dir_err[0] }
235
302
  await fs.promises.writeFile(full, Buffer.from(remote_file.content, 'base64'))
303
+ if (full_report) {
304
+ const report_file = report.files.find(f => f.path === entry.path)
305
+ if (report_file) enrich_file_content(report_file, { remote: decode_b64(remote_file) })
306
+ }
236
307
  }
237
308
  }
238
309
 
239
310
  // Remote-only deleted → remove local
311
+ if (full_report) {
312
+ for (const entry of classified.remote_only_deleted) {
313
+ const report_file = report.files.find(f => f.path === entry.path)
314
+ if (report_file) enrich_file_content(report_file, { base: decode_b64(base_file_map.get(entry.path)) })
315
+ }
316
+ }
240
317
  const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
241
318
  if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
242
319
 
243
320
  // Local-only modified/added → keep (no action needed)
244
321
  // Local-only deleted → keep deleted (no action needed)
322
+ if (full_report) {
323
+ for (const entry of [...classified.local_only_modified, ...classified.local_only_added]) {
324
+ const report_file = report.files.find(f => f.path === entry.path)
325
+ if (report_file) {
326
+ const local_text = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8')
327
+ enrich_file_content(report_file, { local: local_text })
328
+ }
329
+ }
330
+ for (const entry of classified.local_only_deleted) {
331
+ const report_file = report.files.find(f => f.path === entry.path)
332
+ if (report_file) enrich_file_content(report_file, { base: decode_b64(base_file_map.get(entry.path)) })
333
+ }
334
+ }
245
335
 
246
336
  // Both-modified → apply strategy
247
337
  if (strategy === 'theirs') {
248
338
  for (const entry of classified.both_modified) {
339
+ if (full_report) {
340
+ const report_file = report.files.find(f => f.path === entry.path)
341
+ if (report_file) {
342
+ const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
343
+ // Capture local before overwrite
344
+ if (entry.local_sha !== null) {
345
+ try { content.local = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8') } catch { /* deleted */ }
346
+ }
347
+ enrich_file_content(report_file, content)
348
+ }
349
+ }
249
350
  if (entry.remote_sha === null) {
250
351
  // Remote deleted — remove local
251
352
  const full = path.join(skill_dir, entry.path)
@@ -261,7 +362,18 @@ const run = (args) => catch_errors('Pull failed', async () => {
261
362
  }
262
363
  }
263
364
  }
264
- // strategy === 'ours' keep local files as-is (no action needed)
365
+ if (strategy === 'ours' && full_report) {
366
+ for (const entry of classified.both_modified) {
367
+ const report_file = report.files.find(f => f.path === entry.path)
368
+ if (report_file) {
369
+ const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
370
+ if (entry.local_sha !== null) {
371
+ try { content.local = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8') } catch { /* deleted */ }
372
+ }
373
+ enrich_file_content(report_file, content)
374
+ }
375
+ }
376
+ }
265
377
 
266
378
  // Update lock — use head_commit from compare (authoritative, not cached)
267
379
  const [hash_err, new_integrity] = await hash_directory(skill_dir)
@@ -274,6 +386,11 @@ const run = (args) => catch_errors('Pull failed', async () => {
274
386
  version: cmp_data.head_version || lock_entry.version,
275
387
  ref: remote_clone.ref || lock_entry.ref
276
388
  }
389
+ if (conflict_files.length > 0) {
390
+ updated_entry.conflict_files = conflict_files
391
+ } else {
392
+ delete updated_entry.conflict_files
393
+ }
277
394
  const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
278
395
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
279
396
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
@@ -281,15 +398,34 @@ const run = (args) => catch_errors('Pull failed', async () => {
281
398
  spinner.stop()
282
399
 
283
400
  if (args.flags.json) {
284
- print_json({ data: { status: 'merged', report } })
401
+ const status = conflict_files.length > 0 ? 'conflicts' : 'merged'
402
+ const output = { status, report, conflict_files, json_conflicts }
403
+ if (full_report) output.resolution_steps = build_resolution_steps(report, json_conflicts)
404
+ print_json({ data: output })
285
405
  } else {
286
406
  print_success(`Pulled ${skill_name} → ${cmp_data.head_version || 'latest'}`)
287
407
  if (report.summary.auto_merged > 0) {
288
408
  print_info(`${report.summary.auto_merged} file(s) auto-applied (non-conflicting changes)`)
289
409
  }
290
- if (classified.both_modified.length > 0) {
410
+ if (classified.both_modified.length > 0 && strategy) {
291
411
  print_info(`${classified.both_modified.length} conflict(s) resolved with --${strategy}`)
292
412
  }
413
+ const auto_merged_count = classified.both_modified.length - conflict_files.length
414
+ if (auto_merged_count > 0 && !strategy) {
415
+ print_info(`${auto_merged_count} file(s) auto-merged`)
416
+ }
417
+ if (json_conflicts.length > 0) {
418
+ print_warn(`skill.json merge suggestions (review before publishing):`)
419
+ for (const c of json_conflicts) {
420
+ console.error(` - ${c.field}: ${JSON.stringify(c.suggestion)}`)
421
+ }
422
+ }
423
+ if (conflict_files.length > 0) {
424
+ print_warn(`${conflict_files.length} file(s) with conflict markers — resolve before publishing:`)
425
+ for (const f of conflict_files) {
426
+ console.error(` - ${f}`)
427
+ }
428
+ }
293
429
  }
294
430
 
295
431
  // 7. Dependency reconciliation
@@ -25,7 +25,8 @@ Examples:
25
25
  happyskills st acme/deploy-aws
26
26
  happyskills status --json`
27
27
 
28
- const classify = (local_modified, remote_updated) => {
28
+ const classify = (local_modified, remote_updated, has_conflicts) => {
29
+ if (has_conflicts) return 'conflicts'
29
30
  if (local_modified && remote_updated) return 'diverged'
30
31
  if (local_modified) return 'modified'
31
32
  if (remote_updated) return 'outdated'
@@ -78,6 +79,7 @@ const run = (args) => catch_errors('Status failed', async () => {
78
79
  const short_name = name.split('/')[1] || name
79
80
  const dir = skill_install_dir(base_dir, short_name)
80
81
  const [, det] = await detect_status(data, dir)
82
+ const has_conflicts = (data.conflict_files || []).length > 0
81
83
  results.push({
82
84
  skill: name,
83
85
  base_version: data.version || null,
@@ -87,7 +89,8 @@ const run = (args) => catch_errors('Status failed', async () => {
87
89
  remote_updated: false,
88
90
  remote_version: null,
89
91
  remote_commit: null,
90
- status: 'clean'
92
+ conflict_files: data.conflict_files || [],
93
+ status: has_conflicts ? 'conflicts' : 'clean'
91
94
  })
92
95
  }
93
96
 
@@ -112,7 +115,7 @@ const run = (args) => catch_errors('Status failed', async () => {
112
115
  // Classify each result
113
116
  for (const r of results) {
114
117
  if (r.status !== 'not_found') {
115
- r.status = classify(r.local_modified, r.remote_updated)
118
+ r.status = classify(r.local_modified, r.remote_updated, r.conflict_files.length > 0)
116
119
  }
117
120
  }
118
121
 
@@ -131,11 +134,12 @@ const run = (args) => catch_errors('Status failed', async () => {
131
134
  skill: r.skill,
132
135
  base: r.base_version || '?',
133
136
  remote: r.remote_version || '?',
134
- status: r.status === 'diverged' ? 'diverged (local + remote changes)'
135
- : r.status === 'modified' ? 'modified (local changes)'
136
- : r.status === 'outdated' ? 'outdated (remote changes)'
137
- : r.status === 'not_found' ? 'not found'
138
- : 'clean'
137
+ status: r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
138
+ : r.status === 'diverged' ? 'diverged (local + remote changes)'
139
+ : r.status === 'modified' ? 'modified (local changes)'
140
+ : r.status === 'outdated' ? 'outdated (remote changes)'
141
+ : r.status === 'not_found' ? 'not found'
142
+ : 'clean'
139
143
  }))
140
144
 
141
145
  const w_skill = Math.max(col_skill.length, ...rows.map(r => r.skill.length))
@@ -3,6 +3,7 @@ const { error: { catch_errors } } = require('puffy-core')
3
3
  const { validate_skill_md } = require('../validation/skill_md_rules')
4
4
  const { validate_skill_json } = require('../validation/skill_json_rules')
5
5
  const { validate_cross } = require('../validation/cross_rules')
6
+ const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
6
7
  const { file_exists, read_json } = require('../utils/fs')
7
8
  const { skills_dir, find_project_root } = require('../config/paths')
8
9
  const { print_help, print_json } = require('../ui/output')
@@ -144,8 +145,10 @@ const run = (args) => catch_errors('Validate failed', async () => {
144
145
  skill_type
145
146
  )
146
147
  if (cross_err) throw cross_err
148
+ const [marker_err, marker_results] = await validate_no_conflict_markers(skill_dir)
149
+ if (marker_err) throw marker_err
147
150
 
148
- const all_results = [...md_data.results, ...json_data.results, ...cross_results]
151
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
149
152
  const type_label = is_kit ? ' [kit]' : ''
150
153
 
151
154
  if (args.flags.json) {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Three-way merge for CHANGELOG.md files.
3
+ *
4
+ * Strategy:
5
+ * - Parse into version sections (split on ## headings)
6
+ * - Take all remote sections (published history is authoritative)
7
+ * - Prepend local unreleased/new section content
8
+ * - Fall back to text_merge if structure is too ambiguous
9
+ */
10
+
11
+ const { three_way_merge } = require('./text_merge')
12
+
13
+ const VERSION_HEADING_RE = /^##\s+\[/m
14
+
15
+ /**
16
+ * Parse a changelog into header (preamble before first version section) and version sections.
17
+ * Each section starts with a `## [` heading line.
18
+ *
19
+ * @param {string} text
20
+ * @returns {{ header: string, sections: string[] } | null} null if structure is ambiguous
21
+ */
22
+ const parse_sections = (text) => {
23
+ if (!text) return null
24
+
25
+ const lines = text.split('\n')
26
+ let header_end = -1
27
+ const section_starts = []
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ if (VERSION_HEADING_RE.test(lines[i])) {
31
+ if (header_end === -1) header_end = i
32
+ section_starts.push(i)
33
+ }
34
+ }
35
+
36
+ // Ambiguous — no version headings found
37
+ if (section_starts.length === 0) return null
38
+
39
+ const header = lines.slice(0, header_end).join('\n')
40
+ const sections = []
41
+
42
+ for (let i = 0; i < section_starts.length; i++) {
43
+ const start = section_starts[i]
44
+ const end = i + 1 < section_starts.length ? section_starts[i + 1] : lines.length
45
+ sections.push(lines.slice(start, end).join('\n'))
46
+ }
47
+
48
+ return { header, sections }
49
+ }
50
+
51
+ /**
52
+ * Extract the version string from a section heading.
53
+ * e.g. "## [1.5.0] - 2026-03-29" → "1.5.0"
54
+ * e.g. "## [Unreleased]" → "Unreleased"
55
+ */
56
+ const extract_version = (section) => {
57
+ const match = section.match(/^##\s+\[([^\]]+)\]/)
58
+ return match ? match[1] : null
59
+ }
60
+
61
+ /**
62
+ * @param {string} base_text
63
+ * @param {string} local_text
64
+ * @param {string} remote_text
65
+ * @returns {{ merged: string, has_conflicts: boolean, conflict_count: number, conflict_regions: Array, used_fallback: boolean }}
66
+ */
67
+ const merge_changelog = (base_text, local_text, remote_text) => {
68
+ const base_parsed = parse_sections(base_text)
69
+ const local_parsed = parse_sections(local_text)
70
+ const remote_parsed = parse_sections(remote_text)
71
+
72
+ // Fall back to text merge if any side is too ambiguous to parse
73
+ if (!base_parsed || !local_parsed || !remote_parsed) {
74
+ const result = three_way_merge(base_text, local_text, remote_text)
75
+ return { ...result, used_fallback: true }
76
+ }
77
+
78
+ // Collect remote version strings (published history)
79
+ const remote_versions = new Set(remote_parsed.sections.map(extract_version).filter(Boolean))
80
+
81
+ // Find local-only sections: sections in local that are NOT in base and NOT in remote
82
+ const base_versions = new Set(base_parsed.sections.map(extract_version).filter(Boolean))
83
+ const local_new_sections = local_parsed.sections.filter(s => {
84
+ const v = extract_version(s)
85
+ if (!v) return false
86
+ if (v === 'Unreleased') return true
87
+ return !base_versions.has(v) && !remote_versions.has(v)
88
+ })
89
+
90
+ // Build merged changelog: remote header + local new sections + all remote sections
91
+ const parts = [remote_parsed.header]
92
+
93
+ // Add local new sections (unreleased or new version entries)
94
+ for (const section of local_new_sections) {
95
+ parts.push(section)
96
+ }
97
+
98
+ // Add all remote sections (authoritative published history)
99
+ for (const section of remote_parsed.sections) {
100
+ const v = extract_version(section)
101
+ // Skip duplicate unreleased if we already included it from local
102
+ if (v === 'Unreleased' && local_new_sections.some(s => extract_version(s) === 'Unreleased')) {
103
+ continue
104
+ }
105
+ parts.push(section)
106
+ }
107
+
108
+ return {
109
+ merged: parts.join('\n'),
110
+ has_conflicts: false,
111
+ conflict_count: 0,
112
+ conflict_regions: [],
113
+ used_fallback: false
114
+ }
115
+ }
116
+
117
+ module.exports = { merge_changelog, parse_sections, extract_version }
@@ -0,0 +1,92 @@
1
+ const { describe, it } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const { merge_changelog, parse_sections, extract_version } = require('./changelog_merge')
4
+
5
+ describe('parse_sections', () => {
6
+ it('parses header and version sections', () => {
7
+ const text = '# Changelog\n\nPreamble\n\n## [1.0.0] - 2026-01-01\n\nFirst release\n\n## [0.9.0] - 2025-12-01\n\nBeta'
8
+ const r = parse_sections(text)
9
+ assert.strictEqual(r.sections.length, 2)
10
+ assert.ok(r.header.includes('Preamble'))
11
+ assert.ok(r.sections[0].includes('[1.0.0]'))
12
+ assert.ok(r.sections[1].includes('[0.9.0]'))
13
+ })
14
+
15
+ it('returns null for text without version headings', () => {
16
+ assert.strictEqual(parse_sections('just some text'), null)
17
+ assert.strictEqual(parse_sections(null), null)
18
+ })
19
+
20
+ it('handles Unreleased section', () => {
21
+ const text = '# Changelog\n\n## [Unreleased]\n\nWIP\n\n## [1.0.0]\n\nDone'
22
+ const r = parse_sections(text)
23
+ assert.strictEqual(r.sections.length, 2)
24
+ assert.ok(r.sections[0].includes('Unreleased'))
25
+ })
26
+ })
27
+
28
+ describe('extract_version', () => {
29
+ it('extracts version from heading', () => {
30
+ assert.strictEqual(extract_version('## [1.5.0] - 2026-03-29'), '1.5.0')
31
+ assert.strictEqual(extract_version('## [Unreleased]'), 'Unreleased')
32
+ })
33
+
34
+ it('returns null for non-heading text', () => {
35
+ assert.strictEqual(extract_version('some text'), null)
36
+ })
37
+ })
38
+
39
+ describe('merge_changelog', () => {
40
+ const header = '# Changelog\n\nAll notable changes.\n'
41
+
42
+ it('preserves remote history and prepends local new sections', () => {
43
+ const base = `${header}\n## [1.0.0]\n\nBase release`
44
+ const local = `${header}\n## [Unreleased]\n\nLocal work\n\n## [1.0.0]\n\nBase release`
45
+ const remote = `${header}\n## [1.1.0]\n\nRemote release\n\n## [1.0.0]\n\nBase release`
46
+ const r = merge_changelog(base, local, remote)
47
+ assert.strictEqual(r.has_conflicts, false)
48
+ assert.strictEqual(r.used_fallback, false)
49
+ // Local unreleased should appear before remote versions
50
+ const unreleased_pos = r.merged.indexOf('[Unreleased]')
51
+ const v11_pos = r.merged.indexOf('[1.1.0]')
52
+ const v10_pos = r.merged.indexOf('[1.0.0]')
53
+ assert.ok(unreleased_pos < v11_pos, 'Unreleased before 1.1.0')
54
+ assert.ok(v11_pos < v10_pos, '1.1.0 before 1.0.0')
55
+ })
56
+
57
+ it('avoids duplicate Unreleased sections', () => {
58
+ const base = `${header}\n## [1.0.0]\n\nRelease`
59
+ const local = `${header}\n## [Unreleased]\n\nLocal\n\n## [1.0.0]\n\nRelease`
60
+ const remote = `${header}\n## [Unreleased]\n\nRemote\n\n## [1.0.0]\n\nRelease`
61
+ const r = merge_changelog(base, local, remote)
62
+ const count = (r.merged.match(/\[Unreleased\]/g) || []).length
63
+ assert.strictEqual(count, 1, 'Should have exactly one Unreleased section')
64
+ })
65
+
66
+ it('falls back to text_merge for ambiguous structure', () => {
67
+ const base = 'just text'
68
+ const local = 'local text'
69
+ const remote = 'remote text'
70
+ const r = merge_changelog(base, local, remote)
71
+ assert.strictEqual(r.used_fallback, true)
72
+ })
73
+
74
+ it('handles no local changes (remote-only update)', () => {
75
+ const base = `${header}\n## [1.0.0]\n\nRelease`
76
+ const local = base
77
+ const remote = `${header}\n## [1.1.0]\n\nNew\n\n## [1.0.0]\n\nRelease`
78
+ const r = merge_changelog(base, local, remote)
79
+ assert.strictEqual(r.has_conflicts, false)
80
+ assert.ok(r.merged.includes('[1.1.0]'))
81
+ })
82
+
83
+ it('preserves all remote sections as authoritative', () => {
84
+ const base = `${header}\n## [1.0.0]\n\nOriginal`
85
+ const local = `${header}\n## [1.0.0]\n\nOriginal`
86
+ const remote = `${header}\n## [1.2.0]\n\nTwo\n\n## [1.1.0]\n\nOne\n\n## [1.0.0]\n\nOriginal`
87
+ const r = merge_changelog(base, local, remote)
88
+ assert.ok(r.merged.includes('[1.2.0]'))
89
+ assert.ok(r.merged.includes('[1.1.0]'))
90
+ assert.ok(r.merged.includes('[1.0.0]'))
91
+ })
92
+ })