markdown-merge 7.0.0 → 7.1.3
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 +673 -0
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
- 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 +44 -0
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +742 -0
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +199 -0
- data/lib/markdown/merge/debug_logger.rb +26 -0
- data/lib/markdown/merge/document_problems.rb +190 -0
- data/lib/markdown/merge/file_aligner.rb +496 -0
- data/lib/markdown/merge/file_analysis.rb +689 -0
- data/lib/markdown/merge/file_analysis_base.rb +766 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +142 -0
- data/lib/markdown/merge/link_definition_formatter.rb +49 -0
- data/lib/markdown/merge/link_definition_node.rb +157 -0
- data/lib/markdown/merge/link_parser.rb +421 -0
- data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
- 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 +123 -0
- data/lib/markdown/merge/merge_result.rb +483 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +248 -0
- data/lib/markdown/merge/partial_template_merger.rb +555 -0
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/table_match_algorithm.rb +499 -0
- data/lib/markdown/merge/table_match_refiner.rb +132 -0
- data/lib/markdown/merge/version.rb +5 -3
- data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +271 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +62 -0
- data.tar.gz.sig +0 -0
- metadata +289 -15
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Markdown
|
|
6
|
+
module Merge
|
|
7
|
+
# Represents the result of a Markdown merge operation.
|
|
8
|
+
#
|
|
9
|
+
# Inherits from Ast::Merge::MergeResultBase to provide consistent result
|
|
10
|
+
# handling across all merge gems. Contains the merged content along
|
|
11
|
+
# with metadata about conflicts, frozen sections, and changes made.
|
|
12
|
+
#
|
|
13
|
+
# @example Successful merge
|
|
14
|
+
# result = SmartMerger.merge(source_a, source_b)
|
|
15
|
+
# if result.success?
|
|
16
|
+
# File.write("merged.md", result.content)
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Handling conflicts
|
|
20
|
+
# result = SmartMerger.merge(source_a, source_b)
|
|
21
|
+
# if result.conflicts?
|
|
22
|
+
# result.conflicts.each do |conflict|
|
|
23
|
+
# puts "Conflict at: #{conflict[:location]}"
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Checking for document problems
|
|
28
|
+
# result = SmartMerger.merge(source_a, source_b, normalize_whitespace: true)
|
|
29
|
+
# result.problems.by_category(:excessive_whitespace).each do |problem|
|
|
30
|
+
# puts "Whitespace issue at line #{problem.details[:line]}"
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @see Ast::Merge::MergeResultBase Base class
|
|
34
|
+
# @see DocumentProblems For problem tracking
|
|
35
|
+
class MergeResult < Ast::Merge::MergeResultBase
|
|
36
|
+
# @return [DocumentProblems] Problems found during merge
|
|
37
|
+
attr_reader :problems
|
|
38
|
+
|
|
39
|
+
# Initialize a new MergeResult
|
|
40
|
+
#
|
|
41
|
+
# @param content [String, nil] Merged content (nil if merge failed)
|
|
42
|
+
# @param conflicts [Array<Hash>] Conflict descriptions
|
|
43
|
+
# @param frozen_blocks [Array<Hash>] Preserved frozen block info
|
|
44
|
+
# @param stats [Hash] Merge statistics
|
|
45
|
+
# @param problems [DocumentProblems, nil] Document problems found
|
|
46
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
47
|
+
def initialize(content:, raw_content: nil, conflicts: [], frozen_blocks: [], stats: {}, problems: nil, **options)
|
|
48
|
+
super(
|
|
49
|
+
conflicts: conflicts,
|
|
50
|
+
frozen_blocks: frozen_blocks,
|
|
51
|
+
stats: default_stats.merge(stats),
|
|
52
|
+
**options
|
|
53
|
+
)
|
|
54
|
+
@content_raw = content
|
|
55
|
+
@raw_content = raw_content || content
|
|
56
|
+
@problems = problems || DocumentProblems.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get the merged content as a string.
|
|
60
|
+
# Overrides base class to return string content directly.
|
|
61
|
+
#
|
|
62
|
+
# @return [String, nil] The merged Markdown content
|
|
63
|
+
def content
|
|
64
|
+
@content_raw
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if content has been set (not nil).
|
|
68
|
+
# Overrides base class for string-based content.
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def content?
|
|
72
|
+
!@content_raw.nil?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get content as a string (alias for content in this class).
|
|
76
|
+
#
|
|
77
|
+
# @return [String, nil] The merged content
|
|
78
|
+
def content_string
|
|
79
|
+
@content_raw
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if merge was successful (no unresolved conflicts)
|
|
83
|
+
#
|
|
84
|
+
# @return [Boolean] True if merge succeeded
|
|
85
|
+
def success?
|
|
86
|
+
conflicts.empty? && content?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if there are unresolved conflicts
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean] True if conflicts exist
|
|
92
|
+
def conflicts?
|
|
93
|
+
!conflicts.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if any frozen blocks were preserved
|
|
97
|
+
#
|
|
98
|
+
# @return [Boolean] True if frozen blocks were preserved
|
|
99
|
+
def has_frozen_blocks?
|
|
100
|
+
!frozen_blocks.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get count of nodes added during merge
|
|
104
|
+
#
|
|
105
|
+
# @return [Integer] Number of nodes added
|
|
106
|
+
def nodes_added
|
|
107
|
+
stats[:nodes_added] || 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get count of nodes removed during merge
|
|
111
|
+
#
|
|
112
|
+
# @return [Integer] Number of nodes removed
|
|
113
|
+
def nodes_removed
|
|
114
|
+
stats[:nodes_removed] || 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get count of nodes modified during merge
|
|
118
|
+
#
|
|
119
|
+
# @return [Integer] Number of nodes modified
|
|
120
|
+
def nodes_modified
|
|
121
|
+
stats[:nodes_modified] || 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get merge duration in milliseconds
|
|
125
|
+
#
|
|
126
|
+
# @return [Float, nil] Merge time in milliseconds
|
|
127
|
+
def merge_time_ms
|
|
128
|
+
stats[:merge_time_ms]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get count of frozen blocks preserved
|
|
132
|
+
#
|
|
133
|
+
# @return [Integer] Number of frozen blocks
|
|
134
|
+
def frozen_count
|
|
135
|
+
frozen_blocks.size
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# String representation for debugging
|
|
139
|
+
#
|
|
140
|
+
# @return [String] Debug representation
|
|
141
|
+
def inspect
|
|
142
|
+
status = success? ? 'success' : 'failed'
|
|
143
|
+
"#<#{self.class.name} #{status} conflicts=#{conflicts.size} frozen=#{frozen_blocks.size} " \
|
|
144
|
+
"added=#{nodes_added} removed=#{nodes_removed} modified=#{nodes_modified}>"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Convert to string (returns merged content)
|
|
148
|
+
#
|
|
149
|
+
# @return [String] The merged content or empty string
|
|
150
|
+
def to_s
|
|
151
|
+
content || ''
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def to_unresolved_review_state(selections: {}, metadata: {})
|
|
155
|
+
normalized_selections = normalize_unresolved_resolutions(selections)
|
|
156
|
+
Ast::Merge::UnresolvedReviewState.new(
|
|
157
|
+
cases: serializable_unresolved_cases,
|
|
158
|
+
selections: delegated_applied_selections.merge(normalized_selections),
|
|
159
|
+
metadata: review_state_metadata(metadata, normalized_selections)
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def apply_unresolved_resolutions!(resolutions)
|
|
164
|
+
normalized = normalize_unresolved_resolutions(resolutions)
|
|
165
|
+
handled_case_ids = apply_grouped_delegated_resolutions!(normalized)
|
|
166
|
+
remaining = normalized.reject { |case_id, _| handled_case_ids.include?(case_id) }
|
|
167
|
+
apply_output_range_resolutions_descending!(remaining)
|
|
168
|
+
fallback = remaining.reject { |case_id, _| handled_case_ids.include?(case_id) }
|
|
169
|
+
super(fallback)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
protected
|
|
173
|
+
|
|
174
|
+
def apply_non_provisional_unresolved_resolution!(resolution_case, selection:, selected_candidate:)
|
|
175
|
+
raw_output_range = resolution_case.metadata[:output_range]
|
|
176
|
+
if raw_output_range && @content_raw != @raw_content
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"cannot apply non-provisional resolution for case #{resolution_case.case_id} after post-processing transformed markdown output"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
output_range = normalize_output_range(raw_output_range)
|
|
182
|
+
return super unless output_range
|
|
183
|
+
|
|
184
|
+
selected_candidate = resolution_case.metadata.dig(:output_candidate_by_selection,
|
|
185
|
+
selection.to_sym) || selected_candidate
|
|
186
|
+
start_offset, end_offset = output_range
|
|
187
|
+
prefix = @content_raw.byteslice(0, start_offset).to_s
|
|
188
|
+
suffix = @content_raw.byteslice(end_offset..).to_s
|
|
189
|
+
@content_raw = "#{prefix}#{selected_candidate}#{suffix}"
|
|
190
|
+
@raw_content = @content_raw
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
SERIALIZATION_OMIT = Object.new.freeze
|
|
196
|
+
|
|
197
|
+
def apply_grouped_delegated_resolutions!(normalized_resolutions)
|
|
198
|
+
handled_case_ids = []
|
|
199
|
+
delegated_groups = @unresolved_cases
|
|
200
|
+
.group_by { |resolution_case| resolution_case.metadata[:delegated_apply_group] }
|
|
201
|
+
.reject { |group_id, _| group_id.nil? }
|
|
202
|
+
|
|
203
|
+
delegated_groups.each_value do |cases|
|
|
204
|
+
selected_cases = cases.select { |resolution_case| normalized_resolutions.key?(resolution_case.case_id) }
|
|
205
|
+
next if selected_cases.empty?
|
|
206
|
+
|
|
207
|
+
apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
|
|
208
|
+
handled_case_ids.concat(selected_cases.map(&:case_id))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
handled_case_ids
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
|
|
215
|
+
raw_output_range = cases.map { |resolution_case| resolution_case.metadata[:output_range] }.compact.uniq.fetch(0)
|
|
216
|
+
if @content_raw != @raw_content
|
|
217
|
+
raise ArgumentError,
|
|
218
|
+
"cannot apply non-provisional resolution for delegated block #{cases.first.metadata[:delegated_apply_group]} after post-processing transformed markdown output"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
output_range = normalize_output_range(raw_output_range)
|
|
222
|
+
renderer = cases.map do |resolution_case|
|
|
223
|
+
resolution_case.metadata[:delegated_apply_renderer]
|
|
224
|
+
end.compact.uniq.fetch(0)
|
|
225
|
+
prior_selections = cases.map do |resolution_case|
|
|
226
|
+
resolution_case.metadata[:delegated_applied_selections]
|
|
227
|
+
end.compact.reduce({}) do |memo, selections|
|
|
228
|
+
memo.merge(selections.transform_keys(&:to_s))
|
|
229
|
+
end
|
|
230
|
+
prior_root_selections = cases.map do |resolution_case|
|
|
231
|
+
resolution_case.metadata[:delegated_root_applied_selections]
|
|
232
|
+
end.compact.reduce({}) do |memo, selections|
|
|
233
|
+
memo.merge(selections.transform_keys(&:to_s))
|
|
234
|
+
end
|
|
235
|
+
prior_root_identities = cases.map do |resolution_case|
|
|
236
|
+
resolution_case.metadata[:delegated_root_case_identities]
|
|
237
|
+
end.compact.reduce({}) do |memo, identities|
|
|
238
|
+
memo.merge(identities.transform_keys(&:to_s))
|
|
239
|
+
end
|
|
240
|
+
delegated_selections = selected_cases.each_with_object(prior_selections) do |resolution_case, hash|
|
|
241
|
+
hash[resolution_case.metadata[:delegated_case_id]] = normalized_resolutions.fetch(resolution_case.case_id)
|
|
242
|
+
end
|
|
243
|
+
delegated_root_selections = selected_cases.each_with_object(prior_root_selections) do |resolution_case, hash|
|
|
244
|
+
hash[resolution_case.case_id] = normalized_resolutions.fetch(resolution_case.case_id)
|
|
245
|
+
end
|
|
246
|
+
delegated_root_identities = selected_cases.each_with_object(prior_root_identities) do |resolution_case, hash|
|
|
247
|
+
hash[resolution_case.case_id] = review_identity_for_case(resolution_case)
|
|
248
|
+
end
|
|
249
|
+
delegated_result = renderer.call(delegated_selections)
|
|
250
|
+
updated_output_range = replace_output_range!(output_range, delegated_result.fetch(:content))
|
|
251
|
+
previous_case_ids = cases.map(&:case_id)
|
|
252
|
+
@unresolved_cases.reject! { |resolution_case| previous_case_ids.include?(resolution_case.case_id) }
|
|
253
|
+
@conflicts.reject! { |conflict| previous_case_ids.include?(conflict[:case_id].to_s) }
|
|
254
|
+
remapped_cases = remap_delegated_cases_after_apply(
|
|
255
|
+
delegated_result[:unresolved_cases],
|
|
256
|
+
template_case: cases.first,
|
|
257
|
+
output_range: updated_output_range,
|
|
258
|
+
renderer: renderer,
|
|
259
|
+
applied_selections: delegated_selections,
|
|
260
|
+
root_applied_selections: delegated_root_selections,
|
|
261
|
+
root_case_identities: delegated_root_identities
|
|
262
|
+
)
|
|
263
|
+
remapped_cases.each { |resolution_case| add_unresolved_case(resolution_case) }
|
|
264
|
+
@conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def apply_output_range_resolutions_descending!(normalized_resolutions)
|
|
268
|
+
selected_cases = @unresolved_cases.select do |resolution_case|
|
|
269
|
+
normalized_resolutions.key?(resolution_case.case_id) && normalize_output_range(resolution_case.metadata[:output_range])
|
|
270
|
+
end
|
|
271
|
+
selected_cases
|
|
272
|
+
.sort_by { |resolution_case| -normalize_output_range(resolution_case.metadata[:output_range]).first }
|
|
273
|
+
.each do |resolution_case|
|
|
274
|
+
selection = normalized_resolutions.fetch(resolution_case.case_id)
|
|
275
|
+
apply_unresolved_resolution!(resolution_case, selection)
|
|
276
|
+
normalized_resolutions.delete(resolution_case.case_id)
|
|
277
|
+
@conflicts.reject! { |conflict| conflict[:case_id].to_s == resolution_case.case_id }
|
|
278
|
+
end
|
|
279
|
+
selected_case_ids = selected_cases.map(&:case_id)
|
|
280
|
+
@unresolved_cases.reject! { |resolution_case| selected_case_ids.include?(resolution_case.case_id) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def normalize_output_range(value)
|
|
284
|
+
range = Array(value)
|
|
285
|
+
return unless range.length == 2
|
|
286
|
+
|
|
287
|
+
start_offset = range[0].to_i
|
|
288
|
+
end_offset = range[1].to_i
|
|
289
|
+
return unless start_offset >= 0 && end_offset >= start_offset && @raw_content
|
|
290
|
+
return unless end_offset <= @raw_content.bytesize
|
|
291
|
+
|
|
292
|
+
[start_offset, end_offset]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def replace_output_range!(output_range, replacement)
|
|
296
|
+
start_offset, end_offset = output_range
|
|
297
|
+
prefix = @content_raw.byteslice(0, start_offset).to_s
|
|
298
|
+
suffix = @content_raw.byteslice(end_offset..).to_s
|
|
299
|
+
@content_raw = "#{prefix}#{replacement}#{suffix}"
|
|
300
|
+
@raw_content = @content_raw
|
|
301
|
+
[start_offset, start_offset + replacement.to_s.bytesize]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def remap_delegated_cases_after_apply(unresolved_cases, template_case:, output_range:, renderer:,
|
|
305
|
+
applied_selections:, root_applied_selections:, root_case_identities:)
|
|
306
|
+
operation_id = template_case.metadata[:delegated_runtime_operation_id]
|
|
307
|
+
surface_prefix = template_case.metadata[:delegated_runtime_surface_path]
|
|
308
|
+
delegated_group = template_case.metadata[:delegated_apply_group]
|
|
309
|
+
|
|
310
|
+
Array(unresolved_cases).map do |resolution_case|
|
|
311
|
+
suffix = delegated_surface_suffix_for(resolution_case.surface_path)
|
|
312
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
313
|
+
case_id: "#{operation_id}-#{resolution_case.case_id}",
|
|
314
|
+
reason: resolution_case.reason,
|
|
315
|
+
candidates: resolution_case.candidates,
|
|
316
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
317
|
+
surface_path: [surface_prefix, suffix].compact.join(' > '),
|
|
318
|
+
operation_id: operation_id,
|
|
319
|
+
metadata: resolution_case.metadata.merge(
|
|
320
|
+
delegated_case_id: resolution_case.case_id,
|
|
321
|
+
output_range: output_range,
|
|
322
|
+
delegated_apply_group: delegated_group,
|
|
323
|
+
delegated_apply_renderer: renderer,
|
|
324
|
+
delegated_applied_selections: applied_selections,
|
|
325
|
+
delegated_root_applied_selections: root_applied_selections,
|
|
326
|
+
delegated_root_case_identities: root_case_identities,
|
|
327
|
+
delegated_runtime_operation_id: operation_id,
|
|
328
|
+
delegated_runtime_surface_path: surface_prefix
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def delegated_surface_suffix_for(surface_path)
|
|
335
|
+
path = surface_path.to_s
|
|
336
|
+
return if path.empty? || path == 'document[0]'
|
|
337
|
+
|
|
338
|
+
path.sub(/\Adocument\[0\]\s*>\s*/, '')
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def conflict_for_resolution_case(resolution_case)
|
|
342
|
+
{
|
|
343
|
+
case_id: resolution_case.case_id,
|
|
344
|
+
reason: resolution_case.reason,
|
|
345
|
+
template: resolution_case.candidates[:template],
|
|
346
|
+
destination: resolution_case.candidates[:destination],
|
|
347
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
348
|
+
surface_path: resolution_case.surface_path
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def serializable_unresolved_cases
|
|
353
|
+
@unresolved_cases.map do |resolution_case|
|
|
354
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
355
|
+
case_id: resolution_case.case_id,
|
|
356
|
+
reason: resolution_case.reason,
|
|
357
|
+
candidates: resolution_case.candidates,
|
|
358
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
359
|
+
surface_path: resolution_case.surface_path,
|
|
360
|
+
operation_id: resolution_case.operation_id,
|
|
361
|
+
metadata: sanitize_review_state_metadata(
|
|
362
|
+
resolution_case.metadata.merge(review_identity: review_identity_for_case(resolution_case))
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def delegated_applied_selections
|
|
369
|
+
@unresolved_cases.each_with_object({}) do |resolution_case, selections|
|
|
370
|
+
selections.merge!(resolution_case.metadata[:delegated_root_applied_selections].to_h)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def review_state_metadata(metadata, normalized_selections)
|
|
375
|
+
metadata_hash = super(metadata, normalized_selections)
|
|
376
|
+
markdown_review_state = metadata_hash.fetch(:markdown_review_state,
|
|
377
|
+
metadata_hash.fetch('markdown_review_state', {})).to_h
|
|
378
|
+
metadata_hash.merge(
|
|
379
|
+
markdown_review_state: markdown_review_state.merge(
|
|
380
|
+
selection_identities: selection_review_identities(normalized_selections)
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def selection_review_identities(normalized_selections)
|
|
386
|
+
delegated_identities = @unresolved_cases.each_with_object({}) do |resolution_case, identities|
|
|
387
|
+
identities.merge!(resolution_case.metadata[:delegated_root_case_identities].to_h)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
normalized_selections.each_with_object(delegated_identities) do |(case_id, _selection), identities|
|
|
391
|
+
current_case = unresolved_case(case_id)
|
|
392
|
+
identities[case_id.to_s] = review_identity_for_case(current_case) if current_case
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def persisted_selection_identities(metadata)
|
|
397
|
+
markdown_review_state = metadata.fetch(:markdown_review_state, metadata.fetch('markdown_review_state', {})).to_h
|
|
398
|
+
markdown_review_state.fetch(:selection_identities, markdown_review_state.fetch('selection_identities', {})).to_h
|
|
399
|
+
.transform_keys(&:to_s)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def review_identity_for_case(resolution_case)
|
|
403
|
+
persisted_review_identity = case_metadata_value(resolution_case, :review_identity)
|
|
404
|
+
return persisted_review_identity if persisted_review_identity
|
|
405
|
+
|
|
406
|
+
Digest::SHA256.hexdigest(
|
|
407
|
+
[
|
|
408
|
+
resolution_case.surface_path,
|
|
409
|
+
resolution_case.reason,
|
|
410
|
+
resolution_case.provisional_winner,
|
|
411
|
+
case_metadata_value(resolution_case, :match_kind),
|
|
412
|
+
resolution_case.candidates[:template],
|
|
413
|
+
resolution_case.candidates[:destination]
|
|
414
|
+
].map(&:to_s).join("\u001f")
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def sanitize_review_state_metadata(value)
|
|
419
|
+
case value
|
|
420
|
+
when Hash
|
|
421
|
+
value.each_with_object({}) do |(key, entry), hash|
|
|
422
|
+
sanitized = sanitize_review_state_metadata(entry)
|
|
423
|
+
next if sanitized.equal?(SERIALIZATION_OMIT)
|
|
424
|
+
|
|
425
|
+
hash[key] = sanitized
|
|
426
|
+
end
|
|
427
|
+
when Array
|
|
428
|
+
value.filter_map do |entry|
|
|
429
|
+
sanitized = sanitize_review_state_metadata(entry)
|
|
430
|
+
sanitized unless sanitized.equal?(SERIALIZATION_OMIT)
|
|
431
|
+
end
|
|
432
|
+
else
|
|
433
|
+
return SERIALIZATION_OMIT if value.respond_to?(:call)
|
|
434
|
+
|
|
435
|
+
value
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def validate_review_state_compatibility!(state)
|
|
440
|
+
super
|
|
441
|
+
|
|
442
|
+
persisted_cases = state.cases.each_with_object({}) do |resolution_case, hash|
|
|
443
|
+
hash[resolution_case.case_id] = resolution_case
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
state.selections.each_key do |case_id|
|
|
447
|
+
current_case = unresolved_case(case_id)
|
|
448
|
+
persisted_case = persisted_cases[case_id]
|
|
449
|
+
if persisted_case && !case_metadata_value(persisted_case, :review_identity) &&
|
|
450
|
+
(persisted_case.surface_path != current_case.surface_path ||
|
|
451
|
+
case_metadata_value(persisted_case, :match_kind) != case_metadata_value(current_case, :match_kind))
|
|
452
|
+
raise ArgumentError,
|
|
453
|
+
"cannot apply markdown review state: case #{case_id} no longer matches the current unresolved markdown surface"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
next unless current_case&.metadata&.[](:output_range)
|
|
457
|
+
next if @content_raw == @raw_content
|
|
458
|
+
|
|
459
|
+
raise ArgumentError,
|
|
460
|
+
"cannot apply markdown review state for case #{case_id} after post-processing transformed markdown output"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def case_metadata_value(resolution_case, key)
|
|
465
|
+
return unless resolution_case
|
|
466
|
+
|
|
467
|
+
resolution_case.metadata[key] || resolution_case.metadata[key.to_s]
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Default statistics structure
|
|
471
|
+
#
|
|
472
|
+
# @return [Hash] Default stats hash
|
|
473
|
+
def default_stats
|
|
474
|
+
{
|
|
475
|
+
nodes_added: 0,
|
|
476
|
+
nodes_removed: 0,
|
|
477
|
+
nodes_modified: 0,
|
|
478
|
+
merge_time_ms: 0
|
|
479
|
+
}
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Alias for the shared normalizer module from ast-merge
|
|
6
|
+
NodeTypingNormalizer = Ast::Merge::NodeTyping::Normalizer
|
|
7
|
+
|
|
8
|
+
# Normalizes backend-specific node types to canonical markdown types.
|
|
9
|
+
#
|
|
10
|
+
# Uses Ast::Merge::NodeTyping::Wrapper to wrap nodes with canonical
|
|
11
|
+
# merge_type, allowing portable merge rules across backends.
|
|
12
|
+
#
|
|
13
|
+
# ## Thread Safety
|
|
14
|
+
#
|
|
15
|
+
# All backend registration and lookup operations are thread-safe via
|
|
16
|
+
# the shared Ast::Merge::NodeTyping::Normalizer module.
|
|
17
|
+
#
|
|
18
|
+
# ## Extensibility
|
|
19
|
+
#
|
|
20
|
+
# New backends can be registered at runtime:
|
|
21
|
+
#
|
|
22
|
+
# @example Registering a new backend
|
|
23
|
+
# NodeTypeNormalizer.register_backend(:tree_sitter_markdown, {
|
|
24
|
+
# atx_heading: :heading,
|
|
25
|
+
# setext_heading: :heading,
|
|
26
|
+
# fenced_code_block: :code_block,
|
|
27
|
+
# indented_code_block: :code_block,
|
|
28
|
+
# paragraph: :paragraph,
|
|
29
|
+
# bullet_list: :list,
|
|
30
|
+
# ordered_list: :list,
|
|
31
|
+
# block_quote: :block_quote,
|
|
32
|
+
# thematic_break: :thematic_break,
|
|
33
|
+
# html_block: :html_block,
|
|
34
|
+
# pipe_table: :table,
|
|
35
|
+
# })
|
|
36
|
+
#
|
|
37
|
+
# ## Canonical Types
|
|
38
|
+
#
|
|
39
|
+
# The following canonical types are used for portable merge rules:
|
|
40
|
+
# - `:heading` - Headers/headings (H1-H6)
|
|
41
|
+
# - `:paragraph` - Text paragraphs
|
|
42
|
+
# - `:code_block` - Fenced or indented code blocks
|
|
43
|
+
# - `:list` - Ordered or unordered lists
|
|
44
|
+
# - `:block_quote` - Block quotations
|
|
45
|
+
# - `:thematic_break` - Horizontal rules
|
|
46
|
+
# - `:html_block` - Raw HTML blocks
|
|
47
|
+
# - `:table` - Tables (GFM extension)
|
|
48
|
+
# - `:footnote_definition` - Footnote definitions
|
|
49
|
+
# - `:custom_block` - Custom/extension blocks
|
|
50
|
+
#
|
|
51
|
+
# @see Ast::Merge::NodeTyping::Wrapper
|
|
52
|
+
# @see Ast::Merge::NodeTyping::Normalizer
|
|
53
|
+
module NodeTypeNormalizer
|
|
54
|
+
extend NodeTypingNormalizer
|
|
55
|
+
|
|
56
|
+
# Configure default backend mappings.
|
|
57
|
+
# Maps backend-specific type symbols to canonical type symbols.
|
|
58
|
+
#
|
|
59
|
+
# Includes both top-level block types and child node types (table rows, cells, etc.)
|
|
60
|
+
# to enable consistent type checking across the entire AST.
|
|
61
|
+
configure_normalizer(
|
|
62
|
+
commonmarker: {
|
|
63
|
+
# Block types (top-level statements)
|
|
64
|
+
heading: :heading,
|
|
65
|
+
paragraph: :paragraph,
|
|
66
|
+
code_block: :code_block,
|
|
67
|
+
list: :list,
|
|
68
|
+
block_quote: :block_quote,
|
|
69
|
+
thematic_break: :thematic_break,
|
|
70
|
+
html_block: :html_block,
|
|
71
|
+
table: :table,
|
|
72
|
+
footnote_definition: :footnote_definition,
|
|
73
|
+
# Table child types
|
|
74
|
+
table_row: :table_row,
|
|
75
|
+
table_cell: :table_cell,
|
|
76
|
+
table_header: :table_header, # Some parsers distinguish header rows
|
|
77
|
+
# List child types
|
|
78
|
+
list_item: :list_item,
|
|
79
|
+
item: :list_item, # Alias
|
|
80
|
+
# Inline types (usually not top-level, but map them anyway)
|
|
81
|
+
text: :text,
|
|
82
|
+
softbreak: :softbreak,
|
|
83
|
+
linebreak: :linebreak,
|
|
84
|
+
code: :code,
|
|
85
|
+
code_inline: :code, # Alias used by some parsers
|
|
86
|
+
html_inline: :html_inline,
|
|
87
|
+
emph: :emph,
|
|
88
|
+
strong: :strong,
|
|
89
|
+
link: :link,
|
|
90
|
+
image: :image
|
|
91
|
+
}.freeze,
|
|
92
|
+
markly: {
|
|
93
|
+
# Block types - note different names from commonmarker
|
|
94
|
+
header: :heading, # markly uses :header, not :heading
|
|
95
|
+
paragraph: :paragraph,
|
|
96
|
+
code_block: :code_block,
|
|
97
|
+
list: :list,
|
|
98
|
+
blockquote: :block_quote, # markly uses :blockquote, not :block_quote
|
|
99
|
+
hrule: :thematic_break, # markly uses :hrule, not :thematic_break
|
|
100
|
+
html: :html_block, # markly uses :html, not :html_block
|
|
101
|
+
table: :table,
|
|
102
|
+
footnote_definition: :footnote_definition,
|
|
103
|
+
custom_block: :custom_block,
|
|
104
|
+
# Table child types
|
|
105
|
+
table_row: :table_row,
|
|
106
|
+
table_cell: :table_cell,
|
|
107
|
+
table_header: :table_header,
|
|
108
|
+
# List child types
|
|
109
|
+
list_item: :list_item,
|
|
110
|
+
item: :list_item,
|
|
111
|
+
# Inline types
|
|
112
|
+
text: :text,
|
|
113
|
+
softbreak: :softbreak,
|
|
114
|
+
linebreak: :linebreak,
|
|
115
|
+
code: :code,
|
|
116
|
+
code_inline: :code,
|
|
117
|
+
html_inline: :html_inline,
|
|
118
|
+
emph: :emph,
|
|
119
|
+
strong: :strong,
|
|
120
|
+
link: :link,
|
|
121
|
+
image: :image
|
|
122
|
+
}.freeze
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|