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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +99 -482
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
- data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
- data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
- data/lib/markdown/merge/cleanse.rb +5 -3
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +489 -47
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +77 -6
- data/lib/markdown/merge/debug_logger.rb +2 -2
- data/lib/markdown/merge/document_problems.rb +3 -3
- data/lib/markdown/merge/file_aligner.rb +433 -133
- data/lib/markdown/merge/file_analysis.rb +387 -51
- data/lib/markdown/merge/file_analysis_base.rb +188 -51
- data/lib/markdown/merge/gap_line_node.rb +14 -8
- data/lib/markdown/merge/link_definition_node.rb +5 -5
- data/lib/markdown/merge/link_parser.rb +60 -60
- data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
- data/lib/markdown/merge/list_match_refiner.rb +98 -0
- data/lib/markdown/merge/list_merger.rb +322 -0
- data/lib/markdown/merge/markdown_structure.rb +3 -3
- data/lib/markdown/merge/merge_result.rb +321 -4
- data/lib/markdown/merge/node_type_normalizer.rb +6 -6
- data/lib/markdown/merge/output_builder.rb +101 -19
- data/lib/markdown/merge/partial_template_merger.rb +248 -27
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/smart_merger.rb +62 -14
- data/lib/markdown/merge/smart_merger_base.rb +929 -60
- data/lib/markdown/merge/table_match_algorithm.rb +22 -27
- data/lib/markdown/merge/table_match_refiner.rb +6 -10
- data/lib/markdown/merge/version.rb +5 -4
- data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +669 -122
- data/lib/markdown-merge.rb +9 -4
- data/sig/markdown/merge.rbs +3 -336
- data.tar.gz.sig +0 -0
- metadata +104 -93
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -308
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- 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
|