markdown-merge 1.0.3 → 7.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 (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +99 -482
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +5 -3
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +489 -47
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +77 -6
  16. data/lib/markdown/merge/debug_logger.rb +2 -2
  17. data/lib/markdown/merge/document_problems.rb +3 -3
  18. data/lib/markdown/merge/file_aligner.rb +433 -133
  19. data/lib/markdown/merge/file_analysis.rb +387 -51
  20. data/lib/markdown/merge/file_analysis_base.rb +188 -51
  21. data/lib/markdown/merge/gap_line_node.rb +14 -8
  22. data/lib/markdown/merge/link_definition_node.rb +5 -5
  23. data/lib/markdown/merge/link_parser.rb +60 -60
  24. data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
  25. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  26. data/lib/markdown/merge/list_merger.rb +322 -0
  27. data/lib/markdown/merge/markdown_structure.rb +3 -3
  28. data/lib/markdown/merge/merge_result.rb +321 -4
  29. data/lib/markdown/merge/node_type_normalizer.rb +6 -6
  30. data/lib/markdown/merge/output_builder.rb +101 -19
  31. data/lib/markdown/merge/partial_template_merger.rb +248 -27
  32. data/lib/markdown/merge/preservation_support.rb +291 -0
  33. data/lib/markdown/merge/smart_merger.rb +62 -14
  34. data/lib/markdown/merge/smart_merger_base.rb +929 -60
  35. data/lib/markdown/merge/table_match_algorithm.rb +22 -27
  36. data/lib/markdown/merge/table_match_refiner.rb +6 -10
  37. data/lib/markdown/merge/version.rb +5 -4
  38. data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
  39. data/lib/markdown/merge/wrapper_support.rb +194 -0
  40. data/lib/markdown/merge.rb +669 -122
  41. data/lib/markdown-merge.rb +9 -4
  42. data/sig/markdown/merge.rbs +3 -336
  43. data.tar.gz.sig +0 -0
  44. metadata +104 -93
  45. metadata.gz.sig +0 -0
  46. data/CHANGELOG.md +0 -308
  47. data/CITATION.cff +0 -20
  48. data/CODE_OF_CONDUCT.md +0 -134
  49. data/CONTRIBUTING.md +0 -227
  50. data/FUNDING.md +0 -74
  51. data/LICENSE.txt +0 -21
  52. data/REEK +0 -0
  53. data/RUBOCOP.md +0 -71
  54. data/SECURITY.md +0 -21
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Merges two Markdown list nodes at the item level.
6
+ #
7
+ # When a template list and destination list are matched (e.g., via fuzzy matching
8
+ # or a shared content fingerprint), this merger produces a result that is smarter
9
+ # than simply picking one whole list as the winner:
10
+ #
11
+ # - Items that appear in both lists are resolved by preference (template or dest).
12
+ # - Items that only appear in the destination are kept (project customisations).
13
+ # - Items that only appear in the template are added (new canonical steps).
14
+ #
15
+ # Item matching uses significant-token Jaccard overlap so minor wording differences
16
+ # (e.g., "Commit changes" vs "Commit your changes") still produce a match.
17
+ #
18
+ # The merged list is emitted as plain Markdown text (ordered `1. …` lines) and
19
+ # passed to the caller via `add_raw` on the OutputBuilder.
20
+ #
21
+ # @example Basic usage
22
+ # merger = ListMerger.new
23
+ # result = merger.merge_lists(template_node, dest_node,
24
+ # preference: :template,
25
+ # add_template_only_nodes: true,
26
+ # template_analysis: t_analysis,
27
+ # dest_analysis: d_analysis)
28
+ # if result[:merged]
29
+ # builder.add_raw(result[:content])
30
+ # end
31
+ #
32
+ # @see SmartMergerBase#try_inner_merge_list_to_builder
33
+ class ListMerger
34
+ include Ast::Merge::JaccardSimilarity
35
+
36
+ # Minimum Jaccard token overlap to consider two list items as matching.
37
+ ITEM_MATCH_THRESHOLD = 0.35
38
+
39
+ # Merge two list nodes.
40
+ #
41
+ # @param template_node [Object] Template list node (tree_haver / Markly node)
42
+ # @param dest_node [Object] Destination list node
43
+ # @param preference [Symbol] :template or :destination — which wins for matched items
44
+ # @param add_template_only_nodes [Boolean] Whether to append template-only items
45
+ # @param template_analysis [FileAnalysisBase] Template file analysis (for source text)
46
+ # @param dest_analysis [FileAnalysisBase] Destination file analysis (for source text)
47
+ # @return [Hash] { merged: Boolean, content: String } or { merged: false, reason: String }
48
+ def merge_lists(template_node, dest_node,
49
+ preference:,
50
+ add_template_only_nodes: true,
51
+ template_analysis: nil,
52
+ dest_analysis: nil,
53
+ resolution_mode: :eager,
54
+ unresolved_policy: nil)
55
+ t_items = extract_items(template_node)
56
+ d_items = extract_items(dest_node)
57
+
58
+ return not_merged('empty list') if t_items.empty? && d_items.empty?
59
+
60
+ alignment = align_items(t_items, d_items)
61
+ lines, unresolved_cases = emit_lines(
62
+ alignment,
63
+ template_node: template_node,
64
+ dest_node: dest_node,
65
+ preference: preference,
66
+ add_template_only: add_template_only_nodes,
67
+ template_analysis: template_analysis,
68
+ dest_analysis: dest_analysis,
69
+ resolution_mode: resolution_mode,
70
+ unresolved_policy: Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
71
+ )
72
+ return not_merged('no lines emitted') if lines.empty?
73
+
74
+ {
75
+ merged: true,
76
+ content: lines.join("\n") + "\n",
77
+ stats: { decision: unresolved_cases.empty? ? :merged : :unresolved },
78
+ unresolved_cases: unresolved_cases
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ # --- item extraction ---
85
+
86
+ def extract_items(list_node)
87
+ raw = Ast::Merge::NodeTyping.unwrap(list_node)
88
+ children =
89
+ if raw.respond_to?(:to_a)
90
+ raw.to_a
91
+ elsif raw.respond_to?(:children)
92
+ raw.children
93
+ elsif raw.respond_to?(:each)
94
+ raw.each.to_a
95
+ else
96
+ []
97
+ end
98
+
99
+ children.select do |item|
100
+ item.respond_to?(:type) && %w[list_item item].include?(item.type.to_s)
101
+ end
102
+ end
103
+
104
+ # --- alignment ---
105
+
106
+ # Produce an ordered array of alignment entries, each one of:
107
+ # { type: :match, template_item: …, dest_item: … }
108
+ # { type: :dest_only, dest_item: … }
109
+ # { type: :template_only, template_item: … }
110
+ def align_items(t_items, d_items)
111
+ t_tokens = t_items.map { |i| item_tokens(i) }
112
+ d_tokens = d_items.map { |i| item_tokens(i) }
113
+
114
+ matched_t = Set.new
115
+ matched_d = Set.new
116
+ candidates = []
117
+
118
+ t_items.each_with_index do |_t_item, ti|
119
+ d_items.each_with_index do |_d_item, di|
120
+ score = jaccard(t_tokens[ti], d_tokens[di])
121
+ next if score < ITEM_MATCH_THRESHOLD
122
+
123
+ candidates << { score: score, ti: ti, di: di }
124
+ end
125
+ end
126
+
127
+ # Greedy best-first matching
128
+ matches = {} # ti => di
129
+ reverse = {} # di => ti
130
+ candidates.sort_by { |c| -c[:score] }.each do |c|
131
+ next if matched_t.include?(c[:ti]) || matched_d.include?(c[:di])
132
+
133
+ matches[c[:ti]] = c[:di]
134
+ reverse[c[:di]] = c[:ti]
135
+ matched_t << c[:ti]
136
+ matched_d << c[:di]
137
+ end
138
+
139
+ # Walk destination order, interleaving template-only items before the
140
+ # dest item they were adjacent to in the template.
141
+ result = []
142
+ inserted_t = Set.new
143
+
144
+ d_items.each_with_index do |d_item, di|
145
+ # Before this dest item, insert any template-only items whose nearest
146
+ # matched template neighbour falls before this point.
147
+ t_items.each_with_index do |t_item, ti|
148
+ next if matched_t.include?(ti) || inserted_t.include?(ti)
149
+
150
+ # Find the first matched dest index for template items after ti
151
+ next_matched_di = (ti + 1..t_items.size - 1).find { |k| matches.key?(k) }&.then { |k| matches[k] }
152
+ insert_before = next_matched_di.nil? ? d_items.size : next_matched_di
153
+ next if insert_before > di
154
+
155
+ result << { type: :template_only, template_item: t_item }
156
+ inserted_t << ti
157
+ end
158
+
159
+ if reverse.key?(di)
160
+ ti = reverse[di]
161
+ result << { type: :match, template_item: t_items[ti], dest_item: d_item }
162
+ else
163
+ result << { type: :dest_only, dest_item: d_item }
164
+ end
165
+ end
166
+
167
+ # Append remaining template-only items that come after the last dest item
168
+ t_items.each_with_index do |t_item, ti|
169
+ next if matched_t.include?(ti) || inserted_t.include?(ti)
170
+
171
+ result << { type: :template_only, template_item: t_item }
172
+ end
173
+
174
+ result
175
+ end
176
+
177
+ # --- emission ---
178
+
179
+ def emit_lines(alignment, template_node:, dest_node:, preference:, add_template_only:,
180
+ template_analysis:, dest_analysis:, resolution_mode:, unresolved_policy:)
181
+ counter = 1
182
+ lines = []
183
+ unresolved_cases = []
184
+ current_offset = 0
185
+
186
+ alignment.each do |entry|
187
+ case entry[:type]
188
+ when :match
189
+ template_text = item_bare_text(entry[:template_item], template_analysis)
190
+ dest_text = item_bare_text(entry[:dest_item], dest_analysis)
191
+ if unresolved_match?(template_text, dest_text, resolution_mode: resolution_mode,
192
+ unresolved_policy: unresolved_policy)
193
+ provisional_winner = unresolved_policy.provisional_winner_for(:matched_list_item, fallback: preference)
194
+ selected_text = provisional_winner == :template ? template_text : dest_text
195
+ line = format_list_line(counter, selected_text)
196
+ lines << line
197
+ unresolved_cases << build_unresolved_case(
198
+ template_node: template_node,
199
+ dest_node: dest_node,
200
+ template_item: entry[:template_item],
201
+ dest_item: entry[:dest_item],
202
+ template_text: format_list_line(counter, template_text),
203
+ dest_text: format_list_line(counter, dest_text),
204
+ provisional_winner: provisional_winner,
205
+ output_range: [current_offset, current_offset + line.bytesize],
206
+ list_index: counter
207
+ )
208
+ else
209
+ node = preference == :template ? entry[:template_item] : entry[:dest_item]
210
+ analysis = preference == :template ? template_analysis : dest_analysis
211
+ text = item_bare_text(node, analysis)
212
+ lines << format_list_line(counter, text)
213
+ end
214
+ counter += 1
215
+ current_offset += lines.last.bytesize + 1
216
+ when :dest_only
217
+ text = item_bare_text(entry[:dest_item], dest_analysis)
218
+ lines << format_list_line(counter, text)
219
+ counter += 1
220
+ current_offset += lines.last.bytesize + 1
221
+ when :template_only
222
+ next unless add_template_only
223
+
224
+ text = item_bare_text(entry[:template_item], template_analysis)
225
+ lines << format_list_line(counter, text)
226
+ counter += 1
227
+ current_offset += lines.last.bytesize + 1
228
+ end
229
+ end
230
+
231
+ [lines, unresolved_cases]
232
+ end
233
+
234
+ # Return the bare inline text of a list_item node (without the leading `1. ` marker).
235
+ def item_bare_text(item, analysis)
236
+ # Prefer source extraction for fidelity (preserves links, code spans, etc.)
237
+ if analysis && item.respond_to?(:source_position)
238
+ pos = item.source_position
239
+ if pos
240
+ raw = analysis.source_range(pos[:start_line], pos[:end_line]).strip
241
+ # Strip the ordered-list marker: `1. `, `2. `, `123. ` etc.
242
+ return raw.sub(/\A\d+\.\s+/, '')
243
+ end
244
+ end
245
+
246
+ # Fallback: use .text and strip marker
247
+ item.text.to_s.strip.sub(/\A\d+\.\s+/, '')
248
+ end
249
+
250
+ # --- token helpers ---
251
+
252
+ def item_tokens(item)
253
+ text = item.respond_to?(:text) ? item.text.to_s : ''
254
+ extract_tokens(text)
255
+ end
256
+
257
+ def unresolved_match?(template_text, dest_text, resolution_mode:, unresolved_policy:)
258
+ resolution_mode.to_sym == :unresolved &&
259
+ unresolved_policy.unresolved_for?(:matched_list_item) &&
260
+ template_text != dest_text
261
+ end
262
+
263
+ def format_list_line(counter, text)
264
+ "#{counter}. #{text}"
265
+ end
266
+
267
+ def build_unresolved_case(template_node:, dest_node:, template_item:, dest_item:, template_text:, dest_text:,
268
+ provisional_winner:, output_range:, list_index:)
269
+ template_lines = source_span_for(template_item)
270
+ dest_lines = source_span_for(dest_item)
271
+ reference_line = template_lines&.first || dest_lines&.first || list_index
272
+ case_id = "markdown-matched_list_item-#{reference_line}-#{list_index}"
273
+
274
+ Ast::Merge::Runtime::ResolutionCase.new(
275
+ case_id: case_id,
276
+ reason: :conflict,
277
+ candidates: {
278
+ template: template_text,
279
+ destination: dest_text
280
+ },
281
+ provisional_winner: provisional_winner,
282
+ surface_path: "#{list_surface_path(template_node, dest_node)} > matched_list_item[index=#{list_index}]",
283
+ metadata: {
284
+ template_lines: template_lines,
285
+ destination_lines: dest_lines,
286
+ match_kind: :matched_list_item,
287
+ list_index: list_index,
288
+ relative_output_range: output_range
289
+ }.compact
290
+ )
291
+ end
292
+
293
+ def list_surface_path(template_node, dest_node)
294
+ template_lines = source_span_for(template_node)
295
+ dest_lines = source_span_for(dest_node)
296
+ start_line, end_line = template_lines || dest_lines
297
+
298
+ if start_line && end_line
299
+ "document[0] > list[L#{start_line}-L#{end_line}]"
300
+ else
301
+ 'document[0] > list'
302
+ end
303
+ end
304
+
305
+ def source_span_for(node)
306
+ raw = Ast::Merge::NodeTyping.unwrap(node)
307
+ return unless raw.respond_to?(:source_position)
308
+
309
+ position = raw.source_position
310
+ start_line = position&.dig(:start_line)
311
+ end_line = position&.dig(:end_line)
312
+ return unless start_line && end_line
313
+
314
+ [start_line, end_line]
315
+ end
316
+
317
+ def not_merged(reason)
318
+ { merged: false, reason: reason }
319
+ end
320
+ end
321
+ end
322
+ end
@@ -24,6 +24,7 @@ module Markdown
24
24
  # (when preceded by other content)
25
25
  NEEDS_BLANK_BEFORE = %i[
26
26
  heading
27
+ paragraph
27
28
  table
28
29
  code_block
29
30
  thematic_break
@@ -35,6 +36,7 @@ module Markdown
35
36
  # (when followed by other content)
36
37
  NEEDS_BLANK_AFTER = %i[
37
38
  heading
39
+ paragraph
38
40
  table
39
41
  code_block
40
42
  thematic_break
@@ -92,9 +94,7 @@ module Markdown
92
94
  next_sym = next_type.to_sym
93
95
 
94
96
  # Same contiguous type - no blank line between them
95
- if prev_sym == next_sym && contiguous_type?(prev_sym)
96
- return false
97
- end
97
+ return false if prev_sym == next_sym && contiguous_type?(prev_sym)
98
98
 
99
99
  needs_blank_after?(prev_sym) || needs_blank_before?(next_sym)
100
100
  end