json-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.
data/REEK ADDED
File without changes
data/RUBOCOP.md ADDED
@@ -0,0 +1,71 @@
1
+ # RuboCop Usage Guide
2
+
3
+ ## Overview
4
+
5
+ A tale of two RuboCop plugin gems.
6
+
7
+ ### RuboCop Gradual
8
+
9
+ This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
10
+
11
+ ### RuboCop LTS
12
+
13
+ This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
14
+ RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
15
+
16
+ ## Checking RuboCop Violations
17
+
18
+ To check for RuboCop violations in this project, always use:
19
+
20
+ ```bash
21
+ bundle exec rake rubocop_gradual:check
22
+ ```
23
+
24
+ **Do not use** the standard RuboCop commands like:
25
+ - `bundle exec rubocop`
26
+ - `rubocop`
27
+
28
+ ## Understanding the Lock File
29
+
30
+ The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
31
+
32
+ 1. Prevent new violations while gradually fixing existing ones
33
+ 2. Track progress on code style improvements
34
+ 3. Ensure CI builds don't fail due to pre-existing violations
35
+
36
+ ## Common Commands
37
+
38
+ - **Check violations**
39
+ - `bundle exec rake rubocop_gradual`
40
+ - `bundle exec rake rubocop_gradual:check`
41
+ - **(Safe) Autocorrect violations, and update lockfile if no new violations**
42
+ - `bundle exec rake rubocop_gradual:autocorrect`
43
+ - **Force update the lock file (w/o autocorrect) to match violations present in code**
44
+ - `bundle exec rake rubocop_gradual:force_update`
45
+
46
+ ## Workflow
47
+
48
+ 1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
49
+ a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
50
+ 2. If there are new violations, either:
51
+ - Fix them in your code
52
+ - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
53
+ 3. Commit the updated `.rubocop_gradual.lock` file along with your changes
54
+
55
+ ## Never add inline RuboCop disables
56
+
57
+ Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
58
+
59
+ - Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
60
+ - Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
61
+ - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
62
+ - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
63
+
64
+ In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
65
+
66
+ ## Benefits of rubocop_gradual
67
+
68
+ - Allows incremental adoption of code style rules
69
+ - Prevents CI failures due to pre-existing violations
70
+ - Provides a clear record of code style debt
71
+ - Enables focused efforts on improving code quality over time
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |----------|-----------|
7
+ | 1.latest | ✅ |
8
+
9
+ ## Security contact information
10
+
11
+ To report a security vulnerability, please use the
12
+ [Tidelift security contact](https://tidelift.com/security).
13
+ Tidelift will coordinate the fix and disclosure.
14
+
15
+ ## Additional Support
16
+
17
+ If you are interested in support for versions older than the latest release,
18
+ please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
19
+ or find other sponsorship links in the [README].
20
+
21
+ [README]: README.md
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Resolves conflicts between template and destination JSON content
6
+ # using structural signatures and configurable preferences.
7
+ #
8
+ # @example Basic usage
9
+ # resolver = ConflictResolver.new(template_analysis, dest_analysis)
10
+ # resolver.resolve(result)
11
+ class ConflictResolver < ::Ast::Merge::ConflictResolverBase
12
+ # Creates a new ConflictResolver
13
+ #
14
+ # @param template_analysis [FileAnalysis] Analyzed template file
15
+ # @param dest_analysis [FileAnalysis] Analyzed destination file
16
+ # @param preference [Symbol] Which version to prefer when
17
+ # nodes have matching signatures:
18
+ # - :destination (default) - Keep destination version (customizations)
19
+ # - :template - Use template version (updates)
20
+ # @param add_template_only_nodes [Boolean] Whether to add nodes only in template
21
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
22
+ # @param options [Hash] Additional options for forward compatibility
23
+ def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil, **options)
24
+ super(
25
+ strategy: :batch,
26
+ preference: preference,
27
+ template_analysis: template_analysis,
28
+ dest_analysis: dest_analysis,
29
+ add_template_only_nodes: add_template_only_nodes,
30
+ match_refiner: match_refiner,
31
+ **options
32
+ )
33
+ end
34
+
35
+ protected
36
+
37
+ # Resolve conflicts and populate the result using tree-based merging
38
+ #
39
+ # @param result [MergeResult] Result object to populate
40
+ def resolve_batch(result)
41
+ DebugLogger.time("ConflictResolver#resolve") do
42
+ template_statements = @template_analysis.statements
43
+ dest_statements = @dest_analysis.statements
44
+
45
+ # Merge root-level statements (typically just the root object)
46
+ merge_node_lists(
47
+ template_statements,
48
+ dest_statements,
49
+ @template_analysis,
50
+ @dest_analysis,
51
+ result,
52
+ )
53
+
54
+ DebugLogger.debug("Conflict resolution complete", {
55
+ template_statements: template_statements.size,
56
+ dest_statements: dest_statements.size,
57
+ result_lines: result.line_count,
58
+ })
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Recursively merge two lists of nodes (tree-based merge)
65
+ # @param template_nodes [Array<NodeWrapper>] Template nodes
66
+ # @param dest_nodes [Array<NodeWrapper>] Destination nodes
67
+ # @param template_analysis [FileAnalysis] Template analysis for line access
68
+ # @param dest_analysis [FileAnalysis] Destination analysis for line access
69
+ # @param result [MergeResult] Result to populate
70
+ def merge_node_lists(template_nodes, dest_nodes, template_analysis, dest_analysis, result)
71
+ # Build signature maps for matching
72
+ template_by_sig = build_signature_map(template_nodes, template_analysis)
73
+ dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
74
+
75
+ # Build refined matches for nodes that don't match by signature
76
+ refined_matches = build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
77
+ refined_dest_to_template = refined_matches.invert
78
+
79
+ # Track which nodes have been processed
80
+ processed_template_sigs = ::Set.new
81
+ processed_dest_sigs = ::Set.new
82
+
83
+ # First pass: Process destination nodes
84
+ dest_nodes.each do |dest_node|
85
+ dest_sig = dest_analysis.generate_signature(dest_node)
86
+
87
+ # Freeze blocks from destination are always preserved
88
+ if freeze_node?(dest_node)
89
+ add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_FREEZE_BLOCK, dest_analysis)
90
+ processed_dest_sigs << dest_sig if dest_sig
91
+ next
92
+ end
93
+
94
+ # Check for signature match
95
+ if dest_sig && template_by_sig[dest_sig]
96
+ template_info = template_by_sig[dest_sig].first
97
+ template_node = template_info[:node]
98
+
99
+ # Both have this node - merge them (recursively if containers)
100
+ merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
101
+
102
+ processed_dest_sigs << dest_sig
103
+ processed_template_sigs << dest_sig
104
+ elsif refined_dest_to_template.key?(dest_node)
105
+ # Found refined match
106
+ template_node = refined_dest_to_template[dest_node]
107
+ template_sig = template_analysis.generate_signature(template_node)
108
+
109
+ # Merge matched nodes
110
+ merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
111
+
112
+ processed_dest_sigs << dest_sig if dest_sig
113
+ processed_template_sigs << template_sig if template_sig
114
+ else
115
+ # Destination-only node - always keep
116
+ add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_KEPT_DEST, dest_analysis)
117
+ processed_dest_sigs << dest_sig if dest_sig
118
+ end
119
+ end
120
+
121
+ # Second pass: Add template-only nodes if configured
122
+ return unless @add_template_only_nodes
123
+
124
+ template_nodes.each do |template_node|
125
+ template_sig = template_analysis.generate_signature(template_node)
126
+
127
+ # Skip if already processed
128
+ next if template_sig && processed_template_sigs.include?(template_sig)
129
+
130
+ # Skip freeze blocks from template
131
+ next if freeze_node?(template_node)
132
+
133
+ # Add template-only node
134
+ add_node_to_result(template_node, result, :template, MergeResult::DECISION_ADDED, template_analysis)
135
+ processed_template_sigs << template_sig if template_sig
136
+ end
137
+ end
138
+
139
+ # Merge two matched nodes - for containers, recursively merge children
140
+ # @param template_node [NodeWrapper] Template node
141
+ # @param dest_node [NodeWrapper] Destination node
142
+ # @param template_analysis [FileAnalysis] Template analysis
143
+ # @param dest_analysis [FileAnalysis] Destination analysis
144
+ # @param result [MergeResult] Result to populate
145
+ def merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
146
+ if dest_node.container? && template_node.container?
147
+ # Both are containers - recursively merge their children
148
+ merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
149
+ elsif @preference == :destination
150
+ # Leaf nodes or mismatched types - use preference
151
+ add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_KEPT_DEST, dest_analysis)
152
+ else
153
+ add_node_to_result(template_node, result, :template, MergeResult::DECISION_KEPT_TEMPLATE, template_analysis)
154
+ end
155
+ end
156
+
157
+ # Build a map of refined matches from template node to destination node.
158
+ # Uses the match_refiner to find additional pairings for nodes that didn't match by signature.
159
+ # @param template_nodes [Array<NodeWrapper>] Template nodes
160
+ # @param dest_nodes [Array<NodeWrapper>] Destination nodes
161
+ # @param template_by_sig [Hash] Template signature map
162
+ # @param dest_by_sig [Hash] Destination signature map
163
+ # @return [Hash] Map of template_node => dest_node
164
+ def build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
165
+ return {} unless @match_refiner
166
+
167
+ # Find unmatched nodes
168
+ matched_sigs = template_by_sig.keys & dest_by_sig.keys
169
+ unmatched_t_nodes = template_nodes.reject do |n|
170
+ sig = @template_analysis.generate_signature(n)
171
+ sig && matched_sigs.include?(sig)
172
+ end
173
+ unmatched_d_nodes = dest_nodes.reject do |n|
174
+ sig = @dest_analysis.generate_signature(n)
175
+ sig && matched_sigs.include?(sig)
176
+ end
177
+
178
+ return {} if unmatched_t_nodes.empty? || unmatched_d_nodes.empty?
179
+
180
+ # Call the refiner
181
+ matches = @match_refiner.call(unmatched_t_nodes, unmatched_d_nodes, {
182
+ template_analysis: @template_analysis,
183
+ dest_analysis: @dest_analysis,
184
+ })
185
+
186
+ # Build result map: template node -> dest node
187
+ matches.each_with_object({}) do |match, h|
188
+ h[match.template_node] = match.dest_node
189
+ end
190
+ end
191
+
192
+ # Merge two container nodes by emitting opening, recursively merging children, then closing
193
+ # @param template_node [NodeWrapper] Template container node
194
+ # @param dest_node [NodeWrapper] Destination container node
195
+ # @param template_analysis [FileAnalysis] Template analysis
196
+ # @param dest_analysis [FileAnalysis] Destination analysis
197
+ # @param result [MergeResult] Result to populate
198
+ def merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
199
+ # Use destination's opening line (or template if dest doesn't have one)
200
+ opening = dest_node.opening_line || template_node.opening_line
201
+ result.add_line(opening, decision: MergeResult::DECISION_MERGED, source: :merged) if opening
202
+
203
+ # Recursively merge the children
204
+ template_children = template_node.mergeable_children
205
+ dest_children = dest_node.mergeable_children
206
+
207
+ merge_node_lists(
208
+ template_children,
209
+ dest_children,
210
+ template_analysis,
211
+ dest_analysis,
212
+ result,
213
+ )
214
+
215
+ # Use destination's closing line (or template if dest doesn't have one)
216
+ closing = dest_node.closing_line || template_node.closing_line
217
+ result.add_line(closing, decision: MergeResult::DECISION_MERGED, source: :merged) if closing
218
+ end
219
+
220
+ # Add a node to the result (non-container or leaf node)
221
+ # @param node [NodeWrapper] Node to add
222
+ # @param result [MergeResult] Result to populate
223
+ # @param source [Symbol] :template or :destination
224
+ # @param decision [String] Decision constant
225
+ # @param analysis [FileAnalysis] Analysis for line access
226
+ def add_node_to_result(node, result, source, decision, analysis)
227
+ if freeze_node?(node)
228
+ result.add_freeze_block(node)
229
+ elsif node.is_a?(NodeWrapper)
230
+ add_wrapper_to_result(node, result, source, decision, analysis)
231
+ else
232
+ DebugLogger.debug("Unknown node type", {node_type: node.class.name})
233
+ end
234
+ end
235
+
236
+ def add_wrapper_to_result(wrapper, result, source, decision, analysis)
237
+ return unless wrapper.start_line && wrapper.end_line
238
+
239
+ # Add the node content line by line
240
+ (wrapper.start_line..wrapper.end_line).each do |line_num|
241
+ line = analysis.line_at(line_num)
242
+ next unless line
243
+
244
+ result.add_line(line.chomp, decision: decision, source: source, original_line: line_num)
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Debug logging utility for Json::Merge.
6
+ # Extends the base Ast::Merge::DebugLogger with Json-specific configuration.
7
+ #
8
+ # @example Enable debug logging
9
+ # ENV['JSON_MERGE_DEBUG'] = '1'
10
+ # DebugLogger.debug("Processing node", {type: "pair", line: 5})
11
+ #
12
+ # @example Disable debug logging (default)
13
+ # DebugLogger.debug("This won't be printed", {})
14
+ module DebugLogger
15
+ extend Ast::Merge::DebugLogger
16
+
17
+ # Json-specific configuration
18
+ self.env_var_name = "JSON_MERGE_DEBUG"
19
+ self.log_prefix = "[Json::Merge]"
20
+
21
+ class << self
22
+ # Override log_node to handle Json-specific node types.
23
+ #
24
+ # @param node [Object] Node to log information about
25
+ # @param label [String] Label for the node
26
+ def log_node(node, label: "Node")
27
+ return unless enabled?
28
+
29
+ info = case node
30
+ when Json::Merge::NodeWrapper
31
+ {type: node.type.to_s, lines: "#{node.start_line}..#{node.end_line}"}
32
+ else
33
+ extract_node_info(node)
34
+ end
35
+
36
+ debug(label, info)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Custom JSON emitter that preserves comments and formatting.
6
+ # This class provides utilities for emitting JSON while maintaining
7
+ # the original structure, comments, and style choices.
8
+ #
9
+ # @example Basic usage
10
+ # emitter = Emitter.new
11
+ # emitter.emit_object_start
12
+ # emitter.emit_pair("key", '"value"')
13
+ # emitter.emit_object_end
14
+ class Emitter
15
+ # @return [Array<String>] Output lines
16
+ attr_reader :lines
17
+
18
+ # @return [Integer] Current indentation level
19
+ attr_reader :indent_level
20
+
21
+ # @return [Integer] Spaces per indent level
22
+ attr_reader :indent_size
23
+
24
+ # Initialize a new emitter
25
+ #
26
+ # @param indent_size [Integer] Number of spaces per indent level
27
+ def initialize(indent_size: 2)
28
+ @lines = []
29
+ @indent_level = 0
30
+ @indent_size = indent_size
31
+ @needs_comma = false
32
+ end
33
+
34
+ # Emit a single-line comment
35
+ #
36
+ # @param text [String] Comment text (without //)
37
+ # @param inline [Boolean] Whether this is an inline comment
38
+ def emit_comment(text, inline: false)
39
+ if inline
40
+ # Inline comments are appended to the last line
41
+ return if @lines.empty?
42
+
43
+ @lines[-1] = "#{@lines[-1]} // #{text}"
44
+ else
45
+ @lines << "#{current_indent}// #{text}"
46
+ end
47
+ end
48
+
49
+ # Emit a block comment
50
+ #
51
+ # @param text [String] Comment text
52
+ def emit_block_comment(text)
53
+ @lines << "#{current_indent}/* #{text} */"
54
+ end
55
+
56
+ # Emit leading comments
57
+ #
58
+ # @param comments [Array<Hash>] Comment hashes from CommentTracker
59
+ def emit_leading_comments(comments)
60
+ comments.each do |comment|
61
+ indent = " " * (comment[:indent] || 0)
62
+ @lines << if comment[:block]
63
+ "#{indent}/* #{comment[:text]} */"
64
+ else
65
+ "#{indent}// #{comment[:text]}"
66
+ end
67
+ end
68
+ end
69
+
70
+ # Emit a blank line
71
+ def emit_blank_line
72
+ @lines << ""
73
+ end
74
+
75
+ # Emit object start
76
+ def emit_object_start
77
+ add_comma_if_needed
78
+ @lines << "#{current_indent}{"
79
+ @indent_level += 1
80
+ @needs_comma = false
81
+ end
82
+
83
+ # Emit object end
84
+ def emit_object_end
85
+ @indent_level -= 1 if @indent_level > 0
86
+ @lines << "#{current_indent}}"
87
+ @needs_comma = true
88
+ end
89
+
90
+ # Emit array start
91
+ #
92
+ # @param key [String, nil] Key name if this array is a value in an object
93
+ def emit_array_start(key = nil)
94
+ add_comma_if_needed
95
+ @lines << if key
96
+ "#{current_indent}\"#{key}\": ["
97
+ else
98
+ "#{current_indent}["
99
+ end
100
+ @indent_level += 1
101
+ @needs_comma = false
102
+ end
103
+
104
+ # Emit array end
105
+ def emit_array_end
106
+ @indent_level -= 1 if @indent_level > 0
107
+ @lines << "#{current_indent}]"
108
+ @needs_comma = true
109
+ end
110
+
111
+ # Emit a key-value pair
112
+ #
113
+ # @param key [String] Key name (without quotes)
114
+ # @param value [String] Value (already formatted, e.g., '"string"', '123', 'true')
115
+ # @param inline_comment [String, nil] Optional inline comment
116
+ def emit_pair(key, value, inline_comment: nil)
117
+ add_comma_if_needed
118
+ line = "#{current_indent}\"#{key}\": #{value}"
119
+ line += " // #{inline_comment}" if inline_comment
120
+ @lines << line
121
+ @needs_comma = true
122
+ end
123
+
124
+ # Emit an array element
125
+ #
126
+ # @param value [String] Value (already formatted)
127
+ # @param inline_comment [String, nil] Optional inline comment
128
+ def emit_array_element(value, inline_comment: nil)
129
+ add_comma_if_needed
130
+ line = "#{current_indent}#{value}"
131
+ line += " // #{inline_comment}" if inline_comment
132
+ @lines << line
133
+ @needs_comma = true
134
+ end
135
+
136
+ # Emit raw lines (for preserving existing content)
137
+ #
138
+ # @param raw_lines [Array<String>] Lines to emit as-is
139
+ def emit_raw_lines(raw_lines)
140
+ raw_lines.each { |line| @lines << line.chomp }
141
+ end
142
+
143
+ # Get the output as a single string
144
+ #
145
+ # @return [String]
146
+ def to_json
147
+ content = @lines.join("\n")
148
+ content += "\n" unless content.empty? || content.end_with?("\n")
149
+ content
150
+ end
151
+
152
+ # Alias for consistency
153
+ # @return [String]
154
+ alias_method :to_s, :to_json
155
+
156
+ # Clear the output
157
+ def clear
158
+ @lines = []
159
+ @indent_level = 0
160
+ @needs_comma = false
161
+ end
162
+
163
+ private
164
+
165
+ def current_indent
166
+ " " * (@indent_level * @indent_size)
167
+ end
168
+
169
+ def add_comma_if_needed
170
+ return unless @needs_comma && @lines.any?
171
+
172
+ # Add comma to the previous line if it doesn't already have one
173
+ last_line = @lines.last
174
+ return if last_line.strip.empty?
175
+ return if last_line.rstrip.end_with?(",")
176
+ return if last_line.rstrip.end_with?("{")
177
+ return if last_line.rstrip.end_with?("[")
178
+
179
+ @lines[-1] = "#{last_line},"
180
+ end
181
+ end
182
+ end
183
+ end