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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
- require "set"
3
+ require 'digest'
5
4
 
6
5
  module Markdown
7
6
  module Merge
@@ -40,7 +39,7 @@ module Markdown
40
39
 
41
40
  # Default freeze token for identifying freeze blocks
42
41
  # @return [String]
43
- DEFAULT_FREEZE_TOKEN = "markdown-merge"
42
+ DEFAULT_FREEZE_TOKEN = 'markdown-merge'
44
43
 
45
44
  # @return [Object] The root document node
46
45
  attr_reader :document
@@ -48,7 +47,10 @@ module Markdown
48
47
  # @return [Array] Parse errors if any
49
48
  attr_reader :errors
50
49
 
51
- # Note: :source is inherited from Ast::Merge::FileAnalyzable
50
+ # @return [CommentTracker] Comment tracker for this file
51
+ attr_reader :comment_tracker
52
+
53
+ # NOTE: :source is inherited from Ast::Merge::FileAnalyzable
52
54
 
53
55
  # Initialize file analysis
54
56
  #
@@ -61,7 +63,8 @@ module Markdown
61
63
  # But remove the final empty string if source ends with newline
62
64
  # (that empty string represents the "line after the last newline" which doesn't exist)
63
65
  @lines = source.split("\n", -1)
64
- @lines.pop if @lines.last == "" && source.end_with?("\n")
66
+ @lines.pop if @lines.last == '' && source.end_with?("\n")
67
+ @comment_tracker = CommentTracker.new(@lines)
65
68
 
66
69
  @freeze_token = freeze_token
67
70
  @signature_generator = signature_generator
@@ -69,19 +72,19 @@ module Markdown
69
72
  @errors = []
70
73
 
71
74
  # Parse the Markdown source - subclasses implement this
72
- @document = DebugLogger.time("FileAnalysisBase#parse") do
75
+ @document = DebugLogger.time('FileAnalysisBase#parse') do
73
76
  parse_document(source)
74
77
  end
75
78
 
76
79
  # Extract and integrate all nodes including freeze blocks
77
80
  @statements = extract_and_integrate_all_nodes
78
81
 
79
- DebugLogger.debug("FileAnalysisBase initialized", {
80
- signature_generator: signature_generator ? "custom" : "default",
81
- document_children: count_children(@document),
82
- statements_count: @statements.size,
83
- freeze_blocks: freeze_blocks.size,
84
- })
82
+ DebugLogger.debug('FileAnalysisBase initialized', {
83
+ signature_generator: signature_generator ? 'custom' : 'default',
84
+ document_children: count_children(@document),
85
+ statements_count: @statements.size,
86
+ freeze_blocks: freeze_blocks.size
87
+ })
85
88
  end
86
89
 
87
90
  # Parse the source document.
@@ -110,6 +113,106 @@ module Markdown
110
113
  @errors.empty? && !@document.nil?
111
114
  end
112
115
 
116
+ # Get shared comment capability information for this analysis.
117
+ #
118
+ # @return [Object]
119
+ def comment_capability
120
+ @comment_capability ||= comment_tracker.augment(owners: []).capability
121
+ end
122
+
123
+ # Describe how Markdown merges currently own and emit comments.
124
+ #
125
+ # Standalone HTML comments are source-augmented and emitted through the
126
+ # shared synthetic comment layer rather than parser-native comment AST.
127
+ #
128
+ # @return [Ast::Merge::Comment::SupportStyle]
129
+ def comment_support_style
130
+ @comment_support_style ||= shared_comment_support_style(
131
+ source: :markdown_source,
132
+ style: :html_comment,
133
+ read_strategy: :source_augmented_portable_write
134
+ )
135
+ end
136
+
137
+ # Get all tracked comments converted to shared comment nodes.
138
+ #
139
+ # @return [Array]
140
+ def comment_nodes
141
+ comment_tracker.comment_nodes
142
+ end
143
+
144
+ # Get a shared comment node at a specific line.
145
+ #
146
+ # @param line_num [Integer] 1-based line number
147
+ # @return [Object, nil]
148
+ def comment_node_at(line_num)
149
+ comment_tracker.comment_node_at(line_num)
150
+ end
151
+
152
+ # Get comments in a line range converted to a shared comment region.
153
+ #
154
+ # @param range [Range] Range of 1-based line numbers
155
+ # @param kind [Symbol] Region kind
156
+ # @param full_line_only [Boolean] Whether to keep only full-line comments
157
+ # @return [Object]
158
+ def comment_region_for_range(range, kind:, full_line_only: false)
159
+ comment_tracker.comment_region_for_range(
160
+ range,
161
+ kind: kind,
162
+ full_line_only: full_line_only
163
+ )
164
+ end
165
+
166
+ # Build a passive shared comment attachment for an owner.
167
+ #
168
+ # @param owner [Object] Structural owner for the attachment
169
+ # @param options [Hash] Additional metadata / lookup overrides
170
+ # @return [Object]
171
+ def comment_attachment_for(owner, **options)
172
+ augmented_attachment = comment_augmenter(**options).attachment_for(owner)
173
+
174
+ shared_comment_attachment_for(
175
+ owner,
176
+ tracker_attachment: augmented_attachment || comment_tracker.comment_attachment_for(owner, **options),
177
+ **options
178
+ )
179
+ end
180
+
181
+ # @return [Symbol]
182
+ def comment_attachment_strategy
183
+ :normalize_tracked_layout_merge
184
+ end
185
+
186
+ def ruleset_logical_owners
187
+ {
188
+ link_definition: :preserve_if_referenced
189
+ }
190
+ end
191
+
192
+ def ruleset_surfaces
193
+ [
194
+ { name: :fenced_code_block, selector: :language_tag }
195
+ ]
196
+ end
197
+
198
+ def ruleset_delegation_policies
199
+ [
200
+ { surface_name: :fenced_code_block, strategy: :by_language }
201
+ ]
202
+ end
203
+
204
+ # Build a passive shared comment augmenter for this analysis.
205
+ #
206
+ # @param owners [Array, nil] Owners used for attachment inference
207
+ # @param options [Hash] Additional augmenter options
208
+ # @return [Object]
209
+ def comment_augmenter(owners: nil, **options)
210
+ comment_tracker.augment(
211
+ owners: owners || comment_augmenter_default_owners,
212
+ **options
213
+ )
214
+ end
215
+
113
216
  # Get all statements (block nodes outside freeze blocks + FreezeNode instances)
114
217
  # @return [Array<Object, FreezeNode>]
115
218
  attr_reader :statements
@@ -159,8 +262,18 @@ module Markdown
159
262
  type = node.type
160
263
  case type
161
264
  when :heading, :header
162
- # Content-based: Match headings by level and text content
163
- [:heading, node.header_level, extract_text_content(node)]
265
+ level = node.header_level
266
+ # H1 is the document title — treat as a singleton.
267
+ # A well-formed markdown document has exactly one H1. Matching by text
268
+ # would cause a generic template title ("AGENTS.md - Development Guide")
269
+ # and a project-qualified destination title ("AGENTS.md - myGem Development Guide")
270
+ # to be treated as different nodes, keeping both in the merged output.
271
+ # Using level-only for H1 makes them the same structural slot so the
272
+ # preferred version wins cleanly without duplication.
273
+ return [:heading, 1] if level == 1
274
+
275
+ # H2+ match by level and normalized text content
276
+ [:heading, level, extract_text_content(node)]
164
277
  when :paragraph
165
278
  # Content-based: Match paragraphs by content hash (first 32 chars of digest)
166
279
  text = extract_text_content(node)
@@ -171,9 +284,20 @@ module Markdown
171
284
  fence_info = node.respond_to?(:fence_info) ? node.fence_info : nil
172
285
  [:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
173
286
  when :list
174
- # Structure-based: Match lists by type and item count (content may differ)
287
+ # Content-fingerprint: Match lists by type and a hash of the first few
288
+ # items' significant tokens. This lets two lists with similar (but not
289
+ # identical) content match by signature so item-level inner-merge can run,
290
+ # rather than the template list being appended as a template-only node.
175
291
  list_type = node.respond_to?(:list_type) ? node.list_type : nil
176
- [:list, list_type, count_children(node)]
292
+ items_text = []
293
+ child = node.first_child
294
+ while child
295
+ items_text << extract_text_content(child).downcase.gsub(/\W+/, ' ').strip
296
+ child = next_sibling(child)
297
+ break if items_text.size >= 5
298
+ end
299
+ fingerprint = Digest::SHA256.hexdigest(items_text.sort.join('|'))[0, 16]
300
+ [:list, list_type, fingerprint]
177
301
  when :block_quote, :blockquote
178
302
  # Content-based: Match block quotes by content hash
179
303
  text = extract_text_content(node)
@@ -238,10 +362,10 @@ module Markdown
238
362
  # @param end_line [Integer] End line (1-indexed)
239
363
  # @return [String] Source text
240
364
  def source_range(start_line, end_line)
241
- return "" if start_line < 1 || end_line < start_line
365
+ return '' if start_line < 1 || end_line < start_line
242
366
 
243
367
  extracted_lines = @lines[(start_line - 1)..(end_line - 1)]
244
- return "" if extracted_lines.empty?
368
+ return '' if extracted_lines.empty?
245
369
 
246
370
  # Add newlines between and after lines, but not after the last line of the file
247
371
  # unless it originally had one
@@ -267,7 +391,7 @@ module Markdown
267
391
  def extract_table_header_content(node)
268
392
  # First row of a table is typically the header
269
393
  first_row = node.first_child
270
- return "" unless first_row
394
+ return '' unless first_row
271
395
 
272
396
  extract_text_content(first_row)
273
397
  end
@@ -287,6 +411,22 @@ module Markdown
287
411
 
288
412
  private
289
413
 
414
+ def comment_augmenter_default_owners
415
+ @comment_augmenter_default_owners ||= @statements.select do |statement|
416
+ statement.respond_to?(:source_position) && statement.source_position &&
417
+ (!statement.respond_to?(:merge_type) || statement.merge_type != :gap_line) &&
418
+ !standalone_comment_statement?(statement)
419
+ end
420
+ end
421
+
422
+ def standalone_comment_statement?(statement)
423
+ pos = statement.respond_to?(:source_position) ? statement.source_position : nil
424
+ return false unless pos
425
+ return false unless pos[:start_line] && pos[:end_line] && pos[:start_line] == pos[:end_line]
426
+
427
+ comment_tracker.comment_node_at(pos[:start_line])
428
+ end
429
+
290
430
  # Extract all nodes and integrate freeze blocks
291
431
  # @return [Array<Object>] Integrated list of nodes and freeze blocks
292
432
  def extract_and_integrate_all_nodes
@@ -367,15 +507,11 @@ module Markdown
367
507
  # @return [Array<Object>] Gap nodes
368
508
  def create_gap_nodes(line_numbers)
369
509
  line_numbers.map do |line_num|
370
- content = @lines[line_num - 1] || ""
510
+ content = @lines[line_num - 1] || ''
371
511
 
372
512
  # Try to parse as link definition first
373
513
  link_node = LinkDefinitionNode.parse(content, line_number: line_num)
374
- if link_node
375
- link_node
376
- else
377
- GapLineNode.new(content, line_number: line_num)
378
- end
514
+ link_node || GapLineNode.new(content, line_number: line_num)
379
515
  end
380
516
  end
381
517
 
@@ -397,10 +533,17 @@ module Markdown
397
533
  # Set preceding_node for gap lines based on their position in the sorted list
398
534
  # This allows gap lines to have context-aware signatures
399
535
  sorted_nodes.each_with_index do |node, idx|
400
- if node.is_a?(GapLineNode) && idx > 0
401
- # Find the previous non-gap-line node (structural node)
402
- preceding = sorted_nodes[0...idx].reverse.find { |n| !n.is_a?(GapLineNode) }
403
- node.preceding_node = preceding
536
+ next unless node.is_a?(GapLineNode) && idx > 0
537
+
538
+ # Find the previous non-gap-line node (structural node)
539
+ preceding = sorted_nodes[0...idx].reverse.find { |n| !n.is_a?(GapLineNode) }
540
+ node.preceding_node = preceding
541
+ next unless preceding
542
+
543
+ node.preceding_signature = begin
544
+ compute_node_signature(preceding)
545
+ rescue StandardError
546
+ nil
404
547
  end
405
548
  end
406
549
 
@@ -432,7 +575,7 @@ module Markdown
432
575
 
433
576
  # Check HTML nodes for freeze markers
434
577
  # Handle both raw Markly (:html) and TreeHaver normalized ("html_block", :html_block) types
435
- if node_type == :html || node_type == :html_block || node_type == "html_block" || node_type == "html"
578
+ if [:html, :html_block, 'html_block', 'html'].include?(node_type)
436
579
  # Try multiple content extraction methods:
437
580
  # 1. string_content (raw Markly/Commonmarker)
438
581
  # 2. to_commonmark on wrapper
@@ -448,21 +591,15 @@ module Markdown
448
591
  end
449
592
  end
450
593
 
451
- if content.nil? || content.empty?
452
- if child.respond_to?(:to_commonmark)
453
- content = child.to_commonmark.to_s
454
- end
455
- end
594
+ content = child.to_commonmark.to_s if (content.nil? || content.empty?) && child.respond_to?(:to_commonmark)
456
595
 
457
596
  # TreeHaver Commonmarker wrapper stores content in inner_node
458
597
  if (content.nil? || content.empty?) && child.respond_to?(:inner_node)
459
598
  inner = child.inner_node
460
- if inner.respond_to?(:to_commonmark)
461
- content = inner.to_commonmark.to_s
462
- end
599
+ content = inner.to_commonmark.to_s if inner.respond_to?(:to_commonmark)
463
600
  end
464
601
 
465
- content ||= ""
602
+ content ||= ''
466
603
  match = content.match(pattern)
467
604
 
468
605
  if match
@@ -475,7 +612,7 @@ module Markdown
475
612
  type: marker_type.to_sym,
476
613
  text: content.strip,
477
614
  reason: reason,
478
- node: child, # Keep reference to the actual node
615
+ node: child # Keep reference to the actual node
479
616
  }
480
617
  end
481
618
  end
@@ -483,7 +620,7 @@ module Markdown
483
620
  child = next_sibling(child)
484
621
  end
485
622
 
486
- DebugLogger.debug("Found freeze markers", {count: markers.size})
623
+ DebugLogger.debug('Found freeze markers', { count: markers.size })
487
624
  markers
488
625
  end
489
626
 
@@ -503,14 +640,14 @@ module Markdown
503
640
  start_marker = stack.pop
504
641
  blocks << create_freeze_block(start_marker, marker)
505
642
  else
506
- DebugLogger.debug("Unmatched unfreeze marker", {line: marker[:line]})
643
+ DebugLogger.debug('Unmatched unfreeze marker', { line: marker[:line] })
507
644
  end
508
645
  end
509
646
  end
510
647
 
511
648
  # Warn about unclosed freeze blocks
512
649
  stack.each do |unclosed|
513
- DebugLogger.debug("Unclosed freeze marker", {line: unclosed[:line]})
650
+ DebugLogger.debug('Unclosed freeze marker', { line: unclosed[:line] })
514
651
  end
515
652
 
516
653
  blocks.sort_by(&:start_line)
@@ -532,10 +669,10 @@ module Markdown
532
669
  content_end = end_line - 1
533
670
 
534
671
  content = if content_start <= content_end
535
- source_range(content_start, content_end)
536
- else
537
- ""
538
- end
672
+ source_range(content_start, content_end)
673
+ else
674
+ ''
675
+ end
539
676
 
540
677
  # Parse the content to get nodes (for nested analysis)
541
678
  parsed_nodes = parse_freeze_block_content(content)
@@ -547,7 +684,7 @@ module Markdown
547
684
  start_marker: start_marker[:text],
548
685
  end_marker: end_marker[:text],
549
686
  nodes: parsed_nodes,
550
- reason: start_marker[:reason],
687
+ reason: start_marker[:reason]
551
688
  )
552
689
  end
553
690
 
@@ -579,10 +716,10 @@ module Markdown
579
716
  end
580
717
  nodes
581
718
  rescue StandardError => e
582
- # :nocov: defensive - parser rarely fails on valid markdown subset
583
- DebugLogger.debug("Failed to parse freeze block content", {error: e.message})
719
+ # simplecov:disable defensive - parser rarely fails on valid markdown subset
720
+ DebugLogger.debug('Failed to parse freeze block content', { error: e.message })
584
721
  []
585
- # :nocov:
722
+ # simplecov:enable
586
723
  end
587
724
  end
588
725
 
@@ -30,6 +30,9 @@ module Markdown
30
30
  # This is set after integration to avoid circular dependencies during creation
31
31
  attr_accessor :preceding_node
32
32
 
33
+ # @return [Array, nil] Signature of the preceding structural node when available
34
+ attr_accessor :preceding_signature
35
+
33
36
  # Initialize a new GapLineNode
34
37
  #
35
38
  # @param content [String] The line content (without trailing newline)
@@ -37,13 +40,14 @@ module Markdown
37
40
  def initialize(content, line_number:)
38
41
  @content = content.chomp
39
42
  @line_number = line_number
40
- @preceding_node = nil # Set later during integration
43
+ @preceding_node = nil # Set later during integration
44
+ @preceding_signature = nil
41
45
 
42
46
  location = Ast::Merge::AstNode::Location.new(
43
47
  start_line: line_number,
44
48
  end_line: line_number,
45
49
  start_column: 0,
46
- end_column: @content.length,
50
+ end_column: @content.length
47
51
  )
48
52
 
49
53
  super(slice: @content, location: location)
@@ -57,7 +61,7 @@ module Markdown
57
61
 
58
62
  # Alias for compatibility with wrapped nodes that have merge_type
59
63
  # @return [Symbol] :gap_line
60
- alias_method :merge_type, :type
64
+ alias merge_type type
61
65
 
62
66
  # Generate a signature for matching gap lines.
63
67
  # Gap lines are matched by their position relative to the preceding structural node.
@@ -77,11 +81,13 @@ module Markdown
77
81
  # Offset from preceding node's end (e.g., heading ends on line 1, gap is line 2, offset = 1)
78
82
  offset = @line_number - preceding_end_line
79
83
 
80
- # Use the preceding node's type as context (simpler than full signature)
81
- # This works because gap lines after headings match gap lines after headings, etc.
82
- preceding_type = @preceding_node.respond_to?(:type) ? @preceding_node.type : :unknown
84
+ context_signature = @preceding_signature || if @preceding_node.respond_to?(:type)
85
+ @preceding_node.type
86
+ else
87
+ :unknown
88
+ end
83
89
 
84
- [:gap_line_after, preceding_type, offset, @content]
90
+ [:gap_line_after, context_signature, offset, @content]
85
91
  else
86
92
  # Fallback if we can't get position
87
93
  [:gap_line, @line_number, @content]
@@ -99,7 +105,7 @@ module Markdown
99
105
  start_line: @line_number,
100
106
  end_line: @line_number,
101
107
  start_column: 0,
102
- end_column: @content.length,
108
+ end_column: @content.length
103
109
  }
104
110
  end
105
111
 
@@ -61,7 +61,7 @@ module Markdown
61
61
  start_line: line_number,
62
62
  end_line: line_number,
63
63
  start_column: 0,
64
- end_column: content.length,
64
+ end_column: content.length
65
65
  )
66
66
 
67
67
  super(slice: content, location: location)
@@ -71,7 +71,7 @@ module Markdown
71
71
  # Shared parser instance for parsing link definitions
72
72
  # @return [LinkParser]
73
73
  def parser
74
- @parser ||= LinkParser.new # rubocop:disable ThreadSafety/ClassInstanceVariable
74
+ @parser ||= LinkParser.new
75
75
  end
76
76
 
77
77
  # Parse a line and create a LinkDefinitionNode if it's a link definition.
@@ -88,7 +88,7 @@ module Markdown
88
88
  line_number: line_number,
89
89
  label: result[:label],
90
90
  url: result[:url],
91
- title: result[:title],
91
+ title: result[:title]
92
92
  )
93
93
  end
94
94
 
@@ -109,7 +109,7 @@ module Markdown
109
109
 
110
110
  # Alias for compatibility with wrapped nodes that have merge_type
111
111
  # @return [Symbol] :link_definition
112
- alias_method :merge_type, :type
112
+ alias merge_type type
113
113
 
114
114
  # Generate a signature for matching link definitions.
115
115
  # Link definitions are matched by their label (case-insensitive in Markdown).
@@ -126,7 +126,7 @@ module Markdown
126
126
  start_line: @location.start_line,
127
127
  end_line: @location.end_line,
128
128
  start_column: @location.start_column,
129
- end_column: @location.end_column,
129
+ end_column: @location.end_column
130
130
  }
131
131
  end
132
132