markdown-merge 1.0.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +251 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +1087 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
  14. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  15. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
  16. data/lib/markdown/merge/cleanse.rb +42 -0
  17. data/lib/markdown/merge/code_block_merger.rb +300 -0
  18. data/lib/markdown/merge/conflict_resolver.rb +128 -0
  19. data/lib/markdown/merge/debug_logger.rb +26 -0
  20. data/lib/markdown/merge/document_problems.rb +190 -0
  21. data/lib/markdown/merge/file_aligner.rb +196 -0
  22. data/lib/markdown/merge/file_analysis.rb +353 -0
  23. data/lib/markdown/merge/file_analysis_base.rb +629 -0
  24. data/lib/markdown/merge/freeze_node.rb +93 -0
  25. data/lib/markdown/merge/gap_line_node.rb +136 -0
  26. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  27. data/lib/markdown/merge/link_definition_node.rb +157 -0
  28. data/lib/markdown/merge/link_parser.rb +421 -0
  29. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  30. data/lib/markdown/merge/markdown_structure.rb +123 -0
  31. data/lib/markdown/merge/merge_result.rb +166 -0
  32. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  33. data/lib/markdown/merge/output_builder.rb +166 -0
  34. data/lib/markdown/merge/partial_template_merger.rb +334 -0
  35. data/lib/markdown/merge/smart_merger.rb +221 -0
  36. data/lib/markdown/merge/smart_merger_base.rb +621 -0
  37. data/lib/markdown/merge/table_match_algorithm.rb +504 -0
  38. data/lib/markdown/merge/table_match_refiner.rb +136 -0
  39. data/lib/markdown/merge/version.rb +12 -0
  40. data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
  41. data/lib/markdown/merge.rb +149 -0
  42. data/lib/markdown-merge.rb +4 -0
  43. data/sig/markdown/merge.rbs +341 -0
  44. data.tar.gz.sig +0 -0
  45. metadata +365 -0
  46. metadata.gz.sig +0 -0
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Merges fenced code blocks using language-specific *-merge gems.
6
+ #
7
+ # When two code blocks with the same signature are matched, this class
8
+ # delegates the merge to the appropriate language-specific merger:
9
+ # - Ruby code → prism-merge
10
+ # - YAML code → psych-merge
11
+ # - JSON code → json-merge
12
+ # - TOML code → toml-merge
13
+ #
14
+ # @example Basic usage
15
+ # merger = CodeBlockMerger.new
16
+ # result = merger.merge_code_blocks(template_node, dest_node, preference: :destination)
17
+ # if result[:merged]
18
+ # puts result[:content]
19
+ # else
20
+ # # Fall back to standard resolution
21
+ # end
22
+ #
23
+ # @example With custom mergers
24
+ # merger = CodeBlockMerger.new(
25
+ # mergers: {
26
+ # "ruby" => ->(template, dest, pref) { MyCustomRubyMerger.merge(template, dest, pref) },
27
+ # }
28
+ # )
29
+ #
30
+ # @see SmartMergerBase
31
+ # @api public
32
+ class CodeBlockMerger
33
+ # Default language-to-merger mapping
34
+ # Each merger is a lambda that takes (template_content, dest_content, preference)
35
+ # and returns { merged: true/false, content: String, stats: Hash }
36
+ # :nocov: integration - DEFAULT_MERGERS lambdas require external gems
37
+ DEFAULT_MERGERS = {
38
+ # Ruby code blocks
39
+ "ruby" => ->(template, dest, preference, **opts) {
40
+ require "prism/merge"
41
+ CodeBlockMerger.merge_with_prism(template, dest, preference, **opts)
42
+ },
43
+ "rb" => ->(template, dest, preference, **opts) {
44
+ require "prism/merge"
45
+ CodeBlockMerger.merge_with_prism(template, dest, preference, **opts)
46
+ },
47
+
48
+ # YAML code blocks
49
+ "yaml" => ->(template, dest, preference, **opts) {
50
+ require "psych/merge"
51
+ CodeBlockMerger.merge_with_psych(template, dest, preference, **opts)
52
+ },
53
+ "yml" => ->(template, dest, preference, **opts) {
54
+ require "psych/merge"
55
+ CodeBlockMerger.merge_with_psych(template, dest, preference, **opts)
56
+ },
57
+
58
+ # JSON code blocks
59
+ "json" => ->(template, dest, preference, **opts) {
60
+ require "json/merge"
61
+ CodeBlockMerger.merge_with_json(template, dest, preference, **opts)
62
+ },
63
+
64
+ # TOML code blocks
65
+ "toml" => ->(template, dest, preference, **opts) {
66
+ require "toml/merge"
67
+ CodeBlockMerger.merge_with_toml(template, dest, preference, **opts)
68
+ },
69
+ }.freeze
70
+ # :nocov:
71
+
72
+ # @return [Hash<String, Proc>] Language to merger mapping
73
+ attr_reader :mergers
74
+
75
+ # @return [Boolean] Whether inner-merge is enabled
76
+ attr_reader :enabled
77
+
78
+ # Creates a new CodeBlockMerger.
79
+ #
80
+ # @param mergers [Hash<String, Proc>] Custom language-to-merger mapping.
81
+ # Mergers are merged with defaults, allowing selective overrides.
82
+ # @param enabled [Boolean] Whether to enable inner-merge (default: true)
83
+ def initialize(mergers: {}, enabled: true)
84
+ @mergers = DEFAULT_MERGERS.merge(mergers)
85
+ @enabled = enabled
86
+ end
87
+
88
+ # Check if inner-merge is available for a language.
89
+ #
90
+ # @param language [String] The language identifier from fence_info
91
+ # @return [Boolean] true if a merger exists for this language
92
+ def supports_language?(language)
93
+ return false unless @enabled
94
+ return false if language.nil? || language.empty?
95
+
96
+ @mergers.key?(language.downcase)
97
+ end
98
+
99
+ # Merge two code blocks using the appropriate language-specific merger.
100
+ #
101
+ # @param template_node [Object] Template code block node
102
+ # @param dest_node [Object] Destination code block node
103
+ # @param preference [Symbol] :destination or :template
104
+ # @param opts [Hash] Additional options passed to the merger
105
+ # @return [Hash] { merged: Boolean, content: String, stats: Hash }
106
+ def merge_code_blocks(template_node, dest_node, preference:, **opts)
107
+ return not_merged("inner-merge disabled") unless @enabled
108
+
109
+ language = extract_language(template_node) || extract_language(dest_node)
110
+ return not_merged("no language specified") unless language
111
+
112
+ merger = @mergers[language.downcase]
113
+ return not_merged("no merger for language: #{language}") unless merger
114
+
115
+ template_content = extract_content(template_node)
116
+ dest_content = extract_content(dest_node)
117
+
118
+ # If content is identical, no need to merge
119
+ if template_content == dest_content
120
+ return {
121
+ merged: true,
122
+ content: rebuild_code_block(language, dest_content, dest_node),
123
+ stats: {decision: :identical},
124
+ }
125
+ end
126
+
127
+ begin
128
+ result = merger.call(template_content, dest_content, preference, **opts)
129
+ if result[:merged]
130
+ {
131
+ merged: true,
132
+ content: rebuild_code_block(language, result[:content], dest_node),
133
+ stats: result[:stats] || {},
134
+ }
135
+ else
136
+ not_merged(result[:reason] || "merger declined")
137
+ end
138
+ rescue LoadError => e
139
+ not_merged("merger gem not available: #{e.message}")
140
+ rescue TreeHaver::Error => e
141
+ # TreeHaver::NotAvailable and TreeHaver::Error inherit from Exception (not StandardError)
142
+ # for safety reasons related to backend conflicts. We catch them here to handle
143
+ # gracefully when a backend isn't properly configured.
144
+ not_merged("backend not available: #{e.message}")
145
+ rescue StandardError => e
146
+ # :nocov: defensive - Prism::Merge::ParseError handling when prism/merge is loaded
147
+ # Check for Prism::Merge::ParseError if prism/merge is loaded
148
+ if defined?(::Prism::Merge::ParseError) && e.is_a?(::Prism::Merge::ParseError)
149
+ not_merged("Ruby parse error: #{e.message}")
150
+ else
151
+ not_merged("merge failed: #{e.class}: #{e.message}")
152
+ end
153
+ # :nocov:
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ # Extract language from a code block node.
160
+ #
161
+ # @param node [Object] The code block node
162
+ # @return [String, nil] The language identifier
163
+ def extract_language(node)
164
+ return unless node.respond_to?(:fence_info)
165
+
166
+ info = node.fence_info
167
+ return if info.nil? || info.empty?
168
+
169
+ # fence_info may contain additional info after the language (e.g., "ruby linenos")
170
+ info.split(/\s+/).first
171
+ end
172
+
173
+ # Extract content from a code block node.
174
+ #
175
+ # @param node [Object] The code block node
176
+ # @return [String] The code content
177
+ def extract_content(node)
178
+ node.string_content || ""
179
+ end
180
+
181
+ # Rebuild a fenced code block with merged content.
182
+ #
183
+ # @param language [String] The language identifier
184
+ # @param content [String] The merged content
185
+ # @param reference_node [Object] Node to copy fence style from
186
+ # @return [String] The reconstructed code block
187
+ def rebuild_code_block(language, content, reference_node)
188
+ # Ensure content ends with newline for proper fence closing
189
+ content = content.chomp + "\n" unless content.end_with?("\n")
190
+
191
+ # Use backticks as default fence
192
+ fence = "```"
193
+
194
+ "#{fence}#{language}\n#{content}#{fence}"
195
+ end
196
+
197
+ # Return a not-merged result.
198
+ #
199
+ # @param reason [String] Why merge was not performed
200
+ # @return [Hash] Not-merged result hash
201
+ def not_merged(reason)
202
+ {merged: false, reason: reason}
203
+ end
204
+
205
+ class << self
206
+ # Merge Ruby code using prism-merge.
207
+ #
208
+ # @param template [String] Template Ruby code
209
+ # @param dest [String] Destination Ruby code
210
+ # @param preference [Symbol] :destination or :template
211
+ # @return [Hash] Merge result
212
+ # @raise [Prism::Merge::ParseError] If template or dest has syntax errors
213
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
214
+ def merge_with_prism(template, dest, preference, **opts)
215
+ merger = ::Prism::Merge::SmartMerger.new(
216
+ template,
217
+ dest,
218
+ preference: preference,
219
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
220
+ )
221
+
222
+ {
223
+ merged: true,
224
+ content: merger.merge,
225
+ stats: merger.stats,
226
+ }
227
+ end
228
+
229
+ # Merge YAML code using psych-merge.
230
+ #
231
+ # @param template [String] Template YAML code
232
+ # @param dest [String] Destination YAML code
233
+ # @param preference [Symbol] :destination or :template
234
+ # @return [Hash] Merge result
235
+ # @raise [Psych::Merge::ParseError] If template or dest has syntax errors
236
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
237
+ def merge_with_psych(template, dest, preference, **opts)
238
+ merger = ::Psych::Merge::SmartMerger.new(
239
+ template,
240
+ dest,
241
+ preference: preference,
242
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
243
+ )
244
+
245
+ {
246
+ merged: true,
247
+ content: merger.merge,
248
+ stats: merger.stats,
249
+ }
250
+ end
251
+
252
+ # Merge JSON code using json-merge.
253
+ #
254
+ # @param template [String] Template JSON code
255
+ # @param dest [String] Destination JSON code
256
+ # @param preference [Symbol] :destination or :template
257
+ # @return [Hash] Merge result
258
+ # @raise [Json::Merge::ParseError] If template or dest has syntax errors
259
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
260
+ def merge_with_json(template, dest, preference, **opts)
261
+ merger = ::Json::Merge::SmartMerger.new(
262
+ template,
263
+ dest,
264
+ preference: preference,
265
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
266
+ )
267
+
268
+ {
269
+ merged: true,
270
+ content: merger.merge,
271
+ stats: merger.stats,
272
+ }
273
+ end
274
+
275
+ # Merge TOML code using toml-merge.
276
+ #
277
+ # @param template [String] Template TOML code
278
+ # @param dest [String] Destination TOML code
279
+ # @param preference [Symbol] :destination or :template
280
+ # @return [Hash] Merge result
281
+ # @raise [Toml::Merge::ParseError] If template or dest has syntax errors
282
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
283
+ def merge_with_toml(template, dest, preference, **opts)
284
+ merger = ::Toml::Merge::SmartMerger.new(
285
+ template,
286
+ dest,
287
+ preference: preference,
288
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
289
+ )
290
+
291
+ {
292
+ merged: true,
293
+ content: merger.merge,
294
+ stats: merger.stats,
295
+ }
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Resolves conflicts between matching Markdown elements from template and destination.
6
+ #
7
+ # When two elements have the same signature but different content, the resolver
8
+ # determines which version to use based on the configured preference.
9
+ #
10
+ # Inherits from Ast::Merge::ConflictResolverBase using the :node strategy,
11
+ # which resolves conflicts on a per-node-pair basis.
12
+ #
13
+ # @example Basic usage
14
+ # resolver = ConflictResolver.new(
15
+ # preference: :destination,
16
+ # template_analysis: template_analysis,
17
+ # dest_analysis: dest_analysis
18
+ # )
19
+ # resolution = resolver.resolve(template_node, dest_node, template_index: 0, dest_index: 0)
20
+ # case resolution[:source]
21
+ # when :template
22
+ # # Use template version
23
+ # when :destination
24
+ # # Use destination version
25
+ # end
26
+ #
27
+ # @see SmartMergerBase
28
+ # @see Ast::Merge::ConflictResolverBase
29
+ class ConflictResolver < Ast::Merge::ConflictResolverBase
30
+ # Initialize a conflict resolver
31
+ #
32
+ # @param preference [Symbol] Which version to prefer (:destination or :template)
33
+ # @param template_analysis [FileAnalysisBase] Analysis of the template file
34
+ # @param dest_analysis [FileAnalysisBase] Analysis of the destination file
35
+ # @param options [Hash] Additional options for forward compatibility
36
+ def initialize(preference:, template_analysis:, dest_analysis:, **options)
37
+ super(
38
+ strategy: :node,
39
+ preference: preference,
40
+ template_analysis: template_analysis,
41
+ dest_analysis: dest_analysis,
42
+ **options
43
+ )
44
+ end
45
+
46
+ protected
47
+
48
+ # Resolve a conflict between template and destination nodes
49
+ #
50
+ # @param template_node [Object] Node from template
51
+ # @param dest_node [Object] Node from destination
52
+ # @param template_index [Integer] Index in template statements
53
+ # @param dest_index [Integer] Index in destination statements
54
+ # @return [Hash] Resolution with :source, :decision, and node references
55
+ def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
56
+ # Frozen blocks always win
57
+ if freeze_node?(dest_node)
58
+ return frozen_resolution(
59
+ source: :destination,
60
+ template_node: template_node,
61
+ dest_node: dest_node,
62
+ reason: dest_node.reason,
63
+ )
64
+ end
65
+
66
+ if freeze_node?(template_node)
67
+ return frozen_resolution(
68
+ source: :template,
69
+ template_node: template_node,
70
+ dest_node: dest_node,
71
+ reason: template_node.reason,
72
+ )
73
+ end
74
+
75
+ # Check if content is identical
76
+ if content_identical?(template_node, dest_node)
77
+ return identical_resolution(
78
+ template_node: template_node,
79
+ dest_node: dest_node,
80
+ )
81
+ end
82
+
83
+ # Use preference to decide
84
+ preference_resolution(
85
+ template_node: template_node,
86
+ dest_node: dest_node,
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ # Check if two nodes have identical content
93
+ #
94
+ # @param template_node [Object] Template node
95
+ # @param dest_node [Object] Destination node
96
+ # @return [Boolean] True if content is identical
97
+ def content_identical?(template_node, dest_node)
98
+ template_text = node_to_text(template_node, @template_analysis)
99
+ dest_text = node_to_text(dest_node, @dest_analysis)
100
+ template_text == dest_text
101
+ end
102
+
103
+ # Convert a node to its source text
104
+ #
105
+ # @param node [Object] Node to convert
106
+ # @param analysis [FileAnalysisBase] Analysis for source lookup
107
+ # @return [String] Source text
108
+ def node_to_text(node, analysis)
109
+ # Check for any FreezeNode type (base class or subclass)
110
+ if node.is_a?(Ast::Merge::FreezeNodeBase)
111
+ node.full_text
112
+ else
113
+ pos = node.source_position
114
+ start_line = pos&.dig(:start_line)
115
+ end_line = pos&.dig(:end_line)
116
+
117
+ if start_line && end_line
118
+ analysis.source_range(start_line, end_line)
119
+ else
120
+ # :nocov: defensive - Markdown nodes typically have source positions
121
+ node.to_commonmark
122
+ # :nocov:
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Debug logging utility for Markdown::Merge operations.
6
+ #
7
+ # Extends Ast::Merge::DebugLogger to provide consistent logging
8
+ # across all merge gems. Logs are controlled via environment variables.
9
+ #
10
+ # @example Enable debug logging
11
+ # ENV["MARKDOWN_MERGE_DEBUG"] = "1"
12
+ # DebugLogger.debug("Parsing markdown", { file: "README.md" })
13
+ #
14
+ # @example Time an operation
15
+ # result = DebugLogger.time("parse") { Markly.parse(source) }
16
+ #
17
+ # @see Ast::Merge::DebugLogger Base module
18
+ module DebugLogger
19
+ extend Ast::Merge::DebugLogger
20
+
21
+ # Configure for markdown-merge
22
+ self.env_var_name = "MARKDOWN_MERGE_DEBUG"
23
+ self.log_prefix = "[markdown-merge]"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Container for document issues found during processing.
6
+ #
7
+ # Collects problems discovered during merge operations, link reference
8
+ # rehydration, whitespace normalization, and other document transformations.
9
+ # Problems are categorized and have severity levels for filtering and reporting.
10
+ #
11
+ # @example Basic usage
12
+ # problems = DocumentProblems.new
13
+ # problems.add(:duplicate_link_definition, label: "example", url: "https://example.com")
14
+ # problems.add(:excessive_whitespace, line: 42, count: 5, severity: :warning)
15
+ # problems.empty? # => false
16
+ # problems.count # => 2
17
+ #
18
+ # @example Filtering by category
19
+ # problems.by_category(:duplicate_link_definition)
20
+ # # => [{ category: :duplicate_link_definition, label: "example", ... }]
21
+ #
22
+ # @example Filtering by severity
23
+ # problems.by_severity(:error)
24
+ # problems.warnings
25
+ # problems.errors
26
+ #
27
+ class DocumentProblems
28
+ # Problem entry struct
29
+ Problem = Struct.new(:category, :severity, :details, keyword_init: true) do
30
+ def to_h
31
+ {category: category, severity: severity, **details}
32
+ end
33
+
34
+ def warning?
35
+ severity == :warning
36
+ end
37
+
38
+ def error?
39
+ severity == :error
40
+ end
41
+
42
+ def info?
43
+ severity == :info
44
+ end
45
+ end
46
+
47
+ # Valid severity levels
48
+ SEVERITIES = %i[info warning error].freeze
49
+
50
+ # Valid problem categories
51
+ CATEGORIES = %i[
52
+ duplicate_link_definition
53
+ excessive_whitespace
54
+ link_has_title
55
+ image_has_title
56
+ link_ref_spacing
57
+ ].freeze
58
+
59
+ # @return [Array<Problem>] All collected problems
60
+ attr_reader :problems
61
+
62
+ def initialize
63
+ @problems = []
64
+ end
65
+
66
+ # Add a problem to the collection.
67
+ #
68
+ # @param category [Symbol] Problem category (see CATEGORIES)
69
+ # @param severity [Symbol] Severity level (:info, :warning, :error), default :warning
70
+ # @param details [Hash] Additional details about the problem
71
+ # @return [Problem] The added problem
72
+ def add(category, severity: :warning, **details)
73
+ validate_category!(category)
74
+ validate_severity!(severity)
75
+
76
+ problem = Problem.new(category: category, severity: severity, details: details)
77
+ @problems << problem
78
+ problem
79
+ end
80
+
81
+ # Get all problems as an array of hashes.
82
+ #
83
+ # @return [Array<Hash>] All problems
84
+ def all
85
+ @problems.map(&:to_h)
86
+ end
87
+
88
+ # Get problems by category.
89
+ #
90
+ # @param category [Symbol] Category to filter by
91
+ # @return [Array<Problem>] Problems in that category
92
+ def by_category(category)
93
+ @problems.select { |p| p.category == category }
94
+ end
95
+
96
+ # Get problems by severity.
97
+ #
98
+ # @param severity [Symbol] Severity to filter by
99
+ # @return [Array<Problem>] Problems with that severity
100
+ def by_severity(severity)
101
+ @problems.select { |p| p.severity == severity }
102
+ end
103
+
104
+ # Get all info-level problems.
105
+ #
106
+ # @return [Array<Problem>] Info problems
107
+ def infos
108
+ by_severity(:info)
109
+ end
110
+
111
+ # Get all warning-level problems.
112
+ #
113
+ # @return [Array<Problem>] Warning problems
114
+ def warnings
115
+ by_severity(:warning)
116
+ end
117
+
118
+ # Get all error-level problems.
119
+ #
120
+ # @return [Array<Problem>] Error problems
121
+ def errors
122
+ by_severity(:error)
123
+ end
124
+
125
+ # Check if there are any problems.
126
+ #
127
+ # @return [Boolean] true if no problems
128
+ def empty?
129
+ @problems.empty?
130
+ end
131
+
132
+ # Get the count of problems.
133
+ #
134
+ # @param category [Symbol, nil] Optional category filter
135
+ # @param severity [Symbol, nil] Optional severity filter
136
+ # @return [Integer] Problem count
137
+ def count(category: nil, severity: nil)
138
+ filtered = @problems
139
+ filtered = filtered.select { |p| p.category == category } if category
140
+ filtered = filtered.select { |p| p.severity == severity } if severity
141
+ filtered.size
142
+ end
143
+
144
+ # Merge another DocumentProblems into this one.
145
+ #
146
+ # @param other [DocumentProblems] Problems to merge
147
+ # @return [self]
148
+ def merge!(other)
149
+ @problems.concat(other.problems)
150
+ self
151
+ end
152
+
153
+ # Clear all problems.
154
+ #
155
+ # @return [self]
156
+ def clear
157
+ @problems.clear
158
+ self
159
+ end
160
+
161
+ # Get a summary of problems by category.
162
+ #
163
+ # @return [Hash<Symbol, Integer>] Counts by category
164
+ def summary_by_category
165
+ @problems.group_by(&:category).transform_values(&:size)
166
+ end
167
+
168
+ # Get a summary of problems by severity.
169
+ #
170
+ # @return [Hash<Symbol, Integer>] Counts by severity
171
+ def summary_by_severity
172
+ @problems.group_by(&:severity).transform_values(&:size)
173
+ end
174
+
175
+ private
176
+
177
+ def validate_category!(category)
178
+ return if CATEGORIES.include?(category)
179
+
180
+ raise ArgumentError, "Invalid category: #{category}. Valid: #{CATEGORIES.join(", ")}"
181
+ end
182
+
183
+ def validate_severity!(severity)
184
+ return if SEVERITIES.include?(severity)
185
+
186
+ raise ArgumentError, "Invalid severity: #{severity}. Valid: #{SEVERITIES.join(", ")}"
187
+ end
188
+ end
189
+ end
190
+ end