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,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Conservatively tracks standalone HTML comment lines in Markdown sources.
|
|
6
|
+
class CommentTracker < Ast::Merge::Comment::HashTrackerBase
|
|
7
|
+
STANDALONE_HTML_COMMENT_REGEX = /\A(?<indent>\s*)<!--\s?(?<text>.*?)\s?-->\s*\z/
|
|
8
|
+
|
|
9
|
+
def initialize(lines)
|
|
10
|
+
super(Array(lines))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def comment_style
|
|
16
|
+
:html_comment
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def extract_comments
|
|
20
|
+
@lines.each_with_index.filter_map do |line, index|
|
|
21
|
+
match = line.match(STANDALONE_HTML_COMMENT_REGEX)
|
|
22
|
+
next unless match
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
line: index + 1,
|
|
26
|
+
indent: match[:indent].length,
|
|
27
|
+
text: match[:text].to_s,
|
|
28
|
+
full_line: true,
|
|
29
|
+
raw: line
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def owner_line_num(owner)
|
|
35
|
+
pos = owner.respond_to?(:source_position) ? owner.source_position : nil
|
|
36
|
+
return pos[:start_line] if pos && pos[:start_line]
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
@resolution_mode = options.fetch(:resolution_mode, :eager)
|
|
38
|
+
@unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(options[:unresolved_policy])
|
|
39
|
+
super(
|
|
40
|
+
strategy: :node,
|
|
41
|
+
preference: preference,
|
|
42
|
+
template_analysis: template_analysis,
|
|
43
|
+
dest_analysis: dest_analysis,
|
|
44
|
+
**options
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
protected
|
|
49
|
+
|
|
50
|
+
# Resolve a conflict between template and destination nodes
|
|
51
|
+
#
|
|
52
|
+
# @param template_node [Object] Node from template
|
|
53
|
+
# @param dest_node [Object] Node from destination
|
|
54
|
+
# @param template_index [Integer] Index in template statements
|
|
55
|
+
# @param dest_index [Integer] Index in destination statements
|
|
56
|
+
# @return [Hash] Resolution with :source, :decision, and node references
|
|
57
|
+
def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
|
|
58
|
+
# Frozen blocks always win
|
|
59
|
+
if freeze_node?(dest_node)
|
|
60
|
+
return frozen_resolution(
|
|
61
|
+
source: :destination,
|
|
62
|
+
template_node: template_node,
|
|
63
|
+
dest_node: dest_node,
|
|
64
|
+
reason: dest_node.reason
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if freeze_node?(template_node)
|
|
69
|
+
return frozen_resolution(
|
|
70
|
+
source: :template,
|
|
71
|
+
template_node: template_node,
|
|
72
|
+
dest_node: dest_node,
|
|
73
|
+
reason: template_node.reason
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check if content is identical
|
|
78
|
+
if content_identical?(template_node, dest_node)
|
|
79
|
+
return identical_resolution(
|
|
80
|
+
template_node: template_node,
|
|
81
|
+
dest_node: dest_node
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if unresolved_mode? && @unresolved_policy.unresolved_for?(:matched_block)
|
|
86
|
+
return unresolved_resolution(
|
|
87
|
+
template_node: template_node,
|
|
88
|
+
dest_node: dest_node
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Use preference to decide
|
|
93
|
+
preference_resolution(
|
|
94
|
+
template_node: template_node,
|
|
95
|
+
dest_node: dest_node
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Check if two nodes have identical content
|
|
102
|
+
#
|
|
103
|
+
# @param template_node [Object] Template node
|
|
104
|
+
# @param dest_node [Object] Destination node
|
|
105
|
+
# @return [Boolean] True if content is identical
|
|
106
|
+
def content_identical?(template_node, dest_node)
|
|
107
|
+
template_text = node_to_text(template_node, @template_analysis)
|
|
108
|
+
dest_text = node_to_text(dest_node, @dest_analysis)
|
|
109
|
+
template_text == dest_text
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unresolved_resolution(template_node:, dest_node:)
|
|
113
|
+
preferred_resolution = preference_resolution(template_node: template_node, dest_node: dest_node)
|
|
114
|
+
provisional_winner = @unresolved_policy.provisional_winner_for(
|
|
115
|
+
:matched_block,
|
|
116
|
+
fallback: preferred_resolution[:source]
|
|
117
|
+
)
|
|
118
|
+
line = node_line_range(dest_node)&.first || node_line_range(template_node)&.first
|
|
119
|
+
case_id = ['markdown', 'matched_block', line].compact.join('-')
|
|
120
|
+
surface_path = unresolved_surface_path("matched_block[line=#{line}]")
|
|
121
|
+
|
|
122
|
+
unresolved_case = Ast::Merge::Runtime::ResolutionCase.new(
|
|
123
|
+
case_id: case_id,
|
|
124
|
+
reason: :conflict,
|
|
125
|
+
candidates: {
|
|
126
|
+
template: node_to_text(template_node, @template_analysis),
|
|
127
|
+
destination: node_to_text(dest_node, @dest_analysis)
|
|
128
|
+
},
|
|
129
|
+
provisional_winner: provisional_winner,
|
|
130
|
+
surface_path: surface_path,
|
|
131
|
+
metadata: {
|
|
132
|
+
match_kind: :matched_block,
|
|
133
|
+
template_lines: node_line_range(template_node),
|
|
134
|
+
destination_lines: node_line_range(dest_node),
|
|
135
|
+
node_type: markdown_node_type(dest_node || template_node)
|
|
136
|
+
}.compact
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
source: provisional_winner,
|
|
141
|
+
decision: Ast::Merge::MergeResultBase::DECISION_UNRESOLVED,
|
|
142
|
+
template_node: template_node,
|
|
143
|
+
dest_node: dest_node,
|
|
144
|
+
unresolved_case: unresolved_case,
|
|
145
|
+
conflict: {
|
|
146
|
+
case_id: case_id,
|
|
147
|
+
reason: :conflict,
|
|
148
|
+
template: unresolved_case.candidates[:template],
|
|
149
|
+
destination: unresolved_case.candidates[:destination],
|
|
150
|
+
provisional_winner: provisional_winner,
|
|
151
|
+
location: line ? "line #{line}" : nil
|
|
152
|
+
}.compact
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Convert a node to its source text
|
|
157
|
+
#
|
|
158
|
+
# @param node [Object] Node to convert
|
|
159
|
+
# @param analysis [FileAnalysisBase] Analysis for source lookup
|
|
160
|
+
# @return [String] Source text
|
|
161
|
+
def node_to_text(node, analysis)
|
|
162
|
+
# Check for any FreezeNode type (base class or subclass)
|
|
163
|
+
if node.is_a?(Ast::Merge::FreezeNodeBase)
|
|
164
|
+
node.full_text
|
|
165
|
+
else
|
|
166
|
+
pos = node.source_position
|
|
167
|
+
start_line = pos&.dig(:start_line)
|
|
168
|
+
end_line = pos&.dig(:end_line)
|
|
169
|
+
|
|
170
|
+
if start_line && end_line
|
|
171
|
+
analysis.source_range(start_line, end_line)
|
|
172
|
+
else
|
|
173
|
+
# simplecov:disable defensive - Markdown nodes typically have source positions
|
|
174
|
+
node.to_commonmark
|
|
175
|
+
# simplecov:enable
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def node_line_range(node)
|
|
181
|
+
if node.respond_to?(:source_position)
|
|
182
|
+
pos = node.source_position
|
|
183
|
+
start_line = pos&.dig(:start_line)
|
|
184
|
+
end_line = pos&.dig(:end_line)
|
|
185
|
+
return [start_line, end_line] if start_line && end_line
|
|
186
|
+
end
|
|
187
|
+
return [node.start_line, node.end_line] if node.respond_to?(:start_line) && node.respond_to?(:end_line)
|
|
188
|
+
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def markdown_node_type(node)
|
|
193
|
+
return node.type.to_sym if node.respond_to?(:type) && !node.type.nil?
|
|
194
|
+
|
|
195
|
+
node.class.name.split('::').last
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
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
|