html-merge 7.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cb890950b99c62ec12767823ab88ab34b4410b9f4f84fb7d31e97700ee64ec0
4
- data.tar.gz: 25226fc06808f7a1ff22b61abaf7da77fe65092389d83500cdb6a24f4d2a1821
3
+ metadata.gz: 7d1bb35ada60501d9b53cbe2ea82be771de26ad1df6b362625a4fbd2021588a8
4
+ data.tar.gz: 859dfb00db8875f6cbf69896c8a7a5f3ac09c6a3af6ced496917b739152baae6
5
5
  SHA512:
6
- metadata.gz: b152307f494920e718a364fb0b0259eaf6e84837140bf12ed06e9e410209c2b1c31e0adbddd0c775ac27d0347df634aabb5fc98517601e5fbe95d4ca811d1171
7
- data.tar.gz: ee0da4d4af6a087e5b0a24f2c864dc59ebb4ce6a87be14fb7985dbaed539d3334af5ac327762ff10cf0183b5fa43f0d5ed8ea2f36694b55cab07480eb7e63754
6
+ metadata.gz: 8550a9e66927c237939a08ef4cee2743760e4f988e4e68bc616252eff1ae536a446c6474c62f6fe462446d84311de45e943c9914ae04e32ddde2d73fe068a9f9
7
+ data.tar.gz: 0ee6b4338e14e874aaf6b9ace5642a5ddef88a82c01609f3c421be4e11c861c602a717c88175f4e08ec4942a2850ec610c42086c0414031c34ed55c6c317c5b9
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -21,6 +21,10 @@ I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-ta
21
21
 
22
22
  ## 🌻 Synopsis <a href="https://discord.gg/3qme4XHNKN"><img alt="Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/galtzo-floss/avatar-128px.svg" width="8%" align="right"/></a> <a href="https://ruby-toolbox.com"><img alt="ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5" src="https://logos.galtzo.com/assets/images/ruby-lang/avatar-128px.svg" width="8%" align="right"/></a>
23
23
 
24
+ Html::Merge provides TreeHaver-backed HTML parsing and structural analysis for
25
+ the StructuredMerge Ruby family, including feature profiles and YARD content
26
+ wrapper repair helpers.
27
+
24
28
  ## 💡 Info you can shake a stick at
25
29
 
26
30
  | Tokens to Remember | [![Gem name][⛳️name-img]][⛳️gem-name] [![Gem namespace][⛳️namespace-img]][⛳️gem-namespace] |
@@ -36,7 +40,7 @@ I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-ta
36
40
 
37
41
  ### Compatibility
38
42
 
39
- Compatible with MRI Ruby 3.2.0+, and concordant releases of JRuby, and TruffleRuby.
43
+ Compatible with MRI Ruby 3.2.0+, JRuby, and TruffleRuby.
40
44
  CI workflows and Appraisals are generated for MRI Ruby 3.2.0+.
41
45
  This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
42
46
  may be higher than the gem's runtime compatibility floor when legacy Rubies are
@@ -78,9 +82,26 @@ gem install html-merge
78
82
 
79
83
  ## ⚙️ Configuration
80
84
 
85
+ HTML parsing is registered through TreeHaver. The gem currently supports the
86
+ HTML dialect and its configured Kreuzberg language-pack backend; inspect
87
+ `Html::Merge.html_plan_context` before selecting a backend in a host merge
88
+ pipeline. No global initializer is required.
89
+
81
90
  ## 🔧 Basic Usage
82
91
 
83
- TODO: Write usage instructions here
92
+ Parse and inspect HTML through the module API:
93
+
94
+ ```ruby
95
+ require "html/merge"
96
+
97
+ result = Html::Merge.parse_html("<main><h1>Hello</h1></main>")
98
+ raise result[:diagnostics].inspect unless result[:ok]
99
+
100
+ puts result.dig(:analysis, :nodes)
101
+ ```
102
+
103
+ Use `Html::Merge.html_plan_context` to advertise the available backend and
104
+ `Html::Merge.ensure_yard_content_wrapper` when repairing YARD-generated HTML.
84
105
 
85
106
  ## 🔐 Security
86
107
 
@@ -89,20 +110,12 @@ See [SECURITY.md][🔐security].
89
110
  ## 🤝 Contributing
90
111
 
91
112
  If you need some ideas of where to help, you could work on adding more code coverage,
92
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
93
- or use the gem and think about how it could be better.
113
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
94
114
 
95
115
  We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
96
116
 
97
117
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
98
118
 
99
- ### Code Coverage
100
-
101
- <details markdown="1">
102
- <summary>Coverage service badges</summary>
103
-
104
- </details>
105
-
106
119
  ## 📌 Versioning
107
120
 
108
121
  This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html
4
+ module Merge
5
+ # One native parser pass proving HTML structure, explicit IDs, and safe source ranges.
6
+ class FileAnalysis
7
+ attr_reader :source, :root_node, :wrappers, :errors, :backend, :document_semantics, :issues
8
+
9
+ def initialize(source)
10
+ @source = source.to_s
11
+ @backend = BACKEND_REFERENCE.id.to_sym
12
+ @errors = []
13
+ parse!
14
+ rescue StandardError => e
15
+ @root_node = nil
16
+ @wrappers = [].freeze
17
+ @document_semantics = nil
18
+ @issues = [].freeze
19
+ @errors = [e].freeze
20
+ end
21
+
22
+ def valid? = errors.empty?
23
+
24
+ private
25
+
26
+ def parse!
27
+ Html::Merge.register_backend!
28
+ @root_node = TreeHaver.with_backend(backend) { TreeHaver.parser_for(:html).parse(source).root_node }
29
+ raise TreeHaver::NotAvailable, "HTML parse returned no root node" unless root_node
30
+ raise TreeHaver::NotAvailable, "HTML parse contains syntax errors" if root_node.has_error?
31
+
32
+ analyze_structure!
33
+ end
34
+
35
+ def analyze_structure!
36
+ all = []
37
+ collect_elements(root_node, all)
38
+ duplicate_ids = duplicate_ids(all)
39
+ @issues = duplicate_issues(duplicate_ids)
40
+ unique_ids = unique_id_wrappers(all, duplicate_ids)
41
+ owned = topmost_wrappers(unique_ids)
42
+ singletons = singleton_wrappers(all, owned)
43
+ @wrappers = (owned + singletons).sort_by(&:start_byte).freeze
44
+ @document_semantics = semantic_node(root_node)
45
+ end
46
+
47
+ def duplicate_ids(wrappers)
48
+ wrappers.group_by(&:explicit_id).reject { |id, matches| id.nil? || id == true || matches.one? }
49
+ end
50
+
51
+ def duplicate_issues(duplicates)
52
+ duplicates.keys.map do |id|
53
+ { category: :ambiguous_owner, identity: [:id, id],
54
+ message: "duplicate explicit HTML id #{id.inspect}" }.freeze
55
+ end.freeze
56
+ end
57
+
58
+ def unique_id_wrappers(wrappers, duplicates)
59
+ wrappers.select do |wrapper|
60
+ id = wrapper.explicit_id
61
+ id.is_a?(String) && !id.empty? && !duplicates.key?(id)
62
+ end
63
+ end
64
+
65
+ def topmost_wrappers(wrappers)
66
+ wrappers.reject do |wrapper|
67
+ wrappers.any? { |candidate| ancestor?(candidate.node, wrapper.node) }
68
+ end
69
+ end
70
+
71
+ def collect_elements(node, collection, parent = nil)
72
+ wrapper = NodeWrapper.new(node, source: source, parent: parent)
73
+ next_parent = parent
74
+ if wrapper.element?
75
+ collection << wrapper
76
+ next_parent = wrapper
77
+ end
78
+ node.children.each { |child| collect_elements(child, collection, next_parent) }
79
+ end
80
+
81
+ def singleton_wrappers(all, owned)
82
+ %w[title base].filter_map do |tag|
83
+ matches = all.select { |wrapper| wrapper.tag_name == tag }
84
+ next unless matches.one?
85
+ next if owned.include?(matches.first)
86
+ next if owned.any? { |owner| ancestor?(owner.node, matches.first.node) }
87
+
88
+ matches.first
89
+ end
90
+ end
91
+
92
+ def ancestor?(ancestor, descendant)
93
+ ancestor.start_byte < descendant.start_byte && ancestor.end_byte >= descendant.end_byte
94
+ end
95
+
96
+ def semantic_node(node)
97
+ children = node.children.select(&:structural?)
98
+ return [node.type, node.text.to_s] if children.empty?
99
+
100
+ [node.type, children.map { |child| semantic_node(child) }]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html
4
+ module Merge
5
+ # Parser-derived identity, attributes, semantics, and source range for an HTML element.
6
+ class NodeWrapper
7
+ ELEMENT_TYPES = %w[element script_element style_element].freeze
8
+
9
+ attr_reader :node, :source, :parent
10
+
11
+ def initialize(node, source:, parent: nil)
12
+ @node = node
13
+ @source = source
14
+ @parent = parent
15
+ end
16
+
17
+ def element? = ELEMENT_TYPES.include?(node.type)
18
+ def start_byte = node.start_byte
19
+
20
+ def end_byte
21
+ return start_node.end_byte if !explicit_end_tag? && start_node
22
+
23
+ node.end_byte
24
+ end
25
+
26
+ def source_text = source.byteslice(start_byte...end_byte).to_s
27
+ def tag_name = node_text(first_descendant(start_node, "tag_name"))&.downcase
28
+ def explicit_end_tag? = node.children.any? { |child| child.type == "end_tag" }
29
+ def self_closing? = start_node&.type == "self_closing_tag"
30
+
31
+ def attributes
32
+ return {} unless start_node
33
+
34
+ start_node.children.select { |child| child.type == "attribute" }.to_h do |attribute|
35
+ attribute_pair(attribute)
36
+ end.freeze
37
+ end
38
+
39
+ def explicit_id = attributes["id"]
40
+
41
+ def semantic_tree
42
+ semantic_node(node)
43
+ end
44
+
45
+ private
46
+
47
+ def start_node
48
+ node.children.find { |child| %w[start_tag self_closing_tag].include?(child.type) }
49
+ end
50
+
51
+ def attribute_pair(attribute)
52
+ name_node = attribute.children.find { |child| child.type == "attribute_name" }
53
+ value_node = first_descendant(attribute, "attribute_value")
54
+ [node_text(name_node).to_s.downcase, value_node ? node_text(value_node) : true]
55
+ end
56
+
57
+ def first_descendant(value, type)
58
+ return unless value
59
+ return value if value.type == type
60
+
61
+ value.children.each do |child|
62
+ found = first_descendant(child, type)
63
+ return found if found
64
+ end
65
+ nil
66
+ end
67
+
68
+ def node_text(value)
69
+ value&.text&.to_s
70
+ end
71
+
72
+ def semantic_node(value)
73
+ children = value.children.select(&:structural?)
74
+ return [value.type, node_text(value)] if children.empty?
75
+
76
+ [value.type, children.map { |child| semantic_node(child) }]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,755 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "node_wrapper"
6
+ require_relative "file_analysis"
7
+
8
+ module Html
9
+ module Merge
10
+ # Conservative, base-aware HTML provider. Only parser-proven unique IDs and
11
+ # singleton head semantics become independently editable source owners.
12
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- ownership, rendering, and verification are one safety boundary
13
+ class Provider
14
+ DEFAULT_PROFILE = :source_preserving
15
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
16
+ Owner = Data.define(:id, :signature, :fingerprint, :start_byte, :end_byte, :node_start, :node_end,
17
+ :parent_key, :role, :source_text)
18
+ Document = Data.define(:source, :analysis, :owners, :by_id, :unmanaged_fingerprint, :parents)
19
+ ExactDocument = Data.define(:source, :analysis, :issues, :role)
20
+ Decision = Data.define(:changes, :conflicts, :choices)
21
+
22
+ def provider_id = "ruby.html"
23
+ def family = "html"
24
+
25
+ def capabilities
26
+ {
27
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
28
+ dialects: %i[html],
29
+ backends: [BACKEND_REFERENCE.id.to_sym],
30
+ profiles: [DEFAULT_PROFILE],
31
+ role: :workflow,
32
+ ast_ownership: :unique_explicit_ids_and_safe_singletons,
33
+ source_preservation: %i[exact_source exact_fragments byte_provenance reparse semantic_verification]
34
+ }.freeze
35
+ end
36
+
37
+ def analyze(request)
38
+ document = parse_document(:analyze, request, :source)
39
+ return document if provider_failure?(document)
40
+
41
+ result(
42
+ :analyze,
43
+ request,
44
+ analysis: {
45
+ backend: BACKEND_REFERENCE.id,
46
+ valid: true,
47
+ owners: document.owners.map { |owner| owner_description(owner) }
48
+ },
49
+ diagnostics: nonblocking_issues(document.analysis, :source),
50
+ verification: { source_parsed: true, ast_attributes_verified: true }
51
+ )
52
+ end
53
+
54
+ def diff2(request)
55
+ before = parse_document(:diff2, request, :before)
56
+ return before if provider_failure?(before)
57
+
58
+ after = parse_document(:diff2, request, :after)
59
+ return after if provider_failure?(after)
60
+
61
+ changes = diff_documents(before, after)
62
+ result(
63
+ :diff2,
64
+ request,
65
+ diff: { changes: changes },
66
+ changes: changes,
67
+ verification: { before_parsed: true, after_parsed: true, ast_attributes_verified: true }
68
+ )
69
+ end
70
+
71
+ def merge2(request)
72
+ merged = merge3(
73
+ request.merge(
74
+ base_source: request.fetch(:current_source),
75
+ ours_source: request.fetch(:current_source),
76
+ theirs_source: request.fetch(:incoming_source)
77
+ )
78
+ )
79
+ merged.merge(operation: :merge2, verification: merged.fetch(:verification).except(:base_participated))
80
+ end
81
+
82
+ def merge3(request)
83
+ exact_role = exact_revision_role(request)
84
+ return merge3_exact(request, exact_role) if exact_role
85
+
86
+ documents = parse_merge3_documents(request)
87
+ return documents if provider_failure?(documents)
88
+
89
+ decision = decide(documents)
90
+ return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
91
+
92
+ render_composite(request, documents, decision)
93
+ end
94
+
95
+ private
96
+
97
+ def parse_merge3_documents(request)
98
+ %i[base ours theirs].each_with_object({}) do |role, documents|
99
+ parsed = parse_source(request.fetch(:"#{role}_source"), role)
100
+ return unsafe_document_failure(request, role, parsed) if provider_failure?(parsed)
101
+
102
+ documents[role] = parsed
103
+ end
104
+ end
105
+
106
+ def parse_document(operation, request, role)
107
+ key = role == :source ? :source : :"#{role}_source"
108
+ parsed = parse_source(request.fetch(key), role)
109
+ return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
110
+
111
+ parsed
112
+ end
113
+
114
+ def parse_source(source, role)
115
+ analysis = FileAnalysis.new(source)
116
+ return { parse_error: analysis.errors.map(&:to_s).join("; "), source_role: role } unless analysis.valid?
117
+ return { ambiguous_owner: analysis.issues.first, source_role: role } unless analysis.issues.empty?
118
+
119
+ owners = analysis.wrappers.map do |wrapper|
120
+ return { unsafe_range: wrapper.tag_name, source_role: role } unless safe_wrapper?(wrapper, source)
121
+
122
+ start_byte, end_byte = expanded_range(source, wrapper.start_byte, wrapper.end_byte)
123
+ signature = owner_signature(wrapper)
124
+ Owner.new(
125
+ id: owner_id(signature),
126
+ signature: signature,
127
+ fingerprint: fingerprint(wrapper.semantic_tree),
128
+ start_byte: start_byte,
129
+ end_byte: end_byte,
130
+ node_start: wrapper.start_byte,
131
+ node_end: wrapper.end_byte,
132
+ parent_key: parent_key(wrapper),
133
+ role: role,
134
+ source_text: source.byteslice(start_byte...end_byte).to_s
135
+ )
136
+ end
137
+ duplicate = owners.group_by(&:id).find { |_id, matches| matches.length > 1 }
138
+ return { ambiguous_owner: duplicate.first, source_role: role } if duplicate
139
+ return { unsafe_range: :overlapping_owners, source_role: role } unless non_overlapping?(owners)
140
+
141
+ Document.new(
142
+ source: source,
143
+ analysis: analysis,
144
+ owners: owners.freeze,
145
+ by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
146
+ unmanaged_fingerprint: unmanaged_fingerprint(source, owners),
147
+ parents: parent_anchors(analysis, source).freeze
148
+ )
149
+ rescue StandardError => e
150
+ { parse_error: e.message, source_role: role }
151
+ end
152
+
153
+ def parse_exact_source(source, role)
154
+ analysis = FileAnalysis.new(source)
155
+ return { parse_error: analysis.errors.map(&:to_s).join("; "), source_role: role } unless analysis.valid?
156
+
157
+ ExactDocument.new(source: source, analysis: analysis, issues: analysis.issues, role: role)
158
+ rescue StandardError => e
159
+ { parse_error: e.message, source_role: role }
160
+ end
161
+
162
+ def owner_signature(wrapper)
163
+ id = wrapper.explicit_id
164
+ return [:id, id] if id.is_a?(String) && !id.empty?
165
+
166
+ [:singleton, wrapper.tag_name]
167
+ end
168
+
169
+ def owner_id(signature)
170
+ Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signature)))
171
+ end
172
+
173
+ def fingerprint(value)
174
+ Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(value)))
175
+ end
176
+
177
+ def safe_wrapper?(wrapper, source)
178
+ return false unless wrapper.start_byte >= 0 && wrapper.end_byte <= source.bytesize
179
+ return false unless wrapper.end_byte > wrapper.start_byte
180
+
181
+ wrapper.self_closing? || wrapper.explicit_end_tag? || VOID_ELEMENTS.include?(wrapper.tag_name)
182
+ end
183
+
184
+ def expanded_range(source, start_byte, end_byte)
185
+ line_start = source.rindex("\n", [start_byte - 1, 0].max)
186
+ line_start = line_start ? line_start + 1 : 0
187
+ if end_byte.positive? && source.getbyte(end_byte - 1) == 10 &&
188
+ whitespace?(source.byteslice(line_start...start_byte).to_s)
189
+ return [line_start, end_byte]
190
+ end
191
+
192
+ line_end = source.index("\n", end_byte)
193
+ if whitespace?(source.byteslice(line_start...start_byte).to_s) &&
194
+ whitespace?(source.byteslice(end_byte...(line_end || source.bytesize)).to_s)
195
+ [line_start, line_end ? line_end + 1 : source.bytesize]
196
+ else
197
+ [start_byte, end_byte]
198
+ end
199
+ end
200
+
201
+ def whitespace?(value)
202
+ value.each_byte.all? { |byte| [9, 13, 32].include?(byte) }
203
+ end
204
+
205
+ def non_overlapping?(owners)
206
+ owners.each_cons(2).all? { |left, right| left.end_byte <= right.start_byte }
207
+ end
208
+
209
+ def unmanaged_fingerprint(source, owners)
210
+ output = source.dup
211
+ owners.reverse_each { |owner| output[owner.start_byte...owner.end_byte] = "" }
212
+ fingerprint(output)
213
+ end
214
+
215
+ def parent_key(wrapper)
216
+ current = wrapper.parent
217
+ while current
218
+ id = current.explicit_id
219
+ return [:id, id] if id.is_a?(String) && !id.empty?
220
+ return [:boundary, current.tag_name] if %w[html head body].include?(current.tag_name)
221
+
222
+ current = current.parent
223
+ end
224
+ [:document]
225
+ end
226
+
227
+ def parent_anchors(analysis, source)
228
+ anchors = { [:document] => source.bytesize }
229
+ walk = lambda do |node|
230
+ wrapper = NodeWrapper.new(node, source: source)
231
+ if wrapper.element?
232
+ id = wrapper.explicit_id
233
+ anchors[[:id, id]] = insertion_anchor(node) if id.is_a?(String) && !id.empty?
234
+ anchors[[:boundary, wrapper.tag_name]] = insertion_anchor(node) if %w[html head
235
+ body].include?(wrapper.tag_name)
236
+ end
237
+ node.children.each { |child| walk.call(child) }
238
+ end
239
+ walk.call(analysis.root_node)
240
+ anchors.compact
241
+ end
242
+
243
+ def insertion_anchor(node)
244
+ node.children.find { |child| child.type == "end_tag" }&.start_byte
245
+ end
246
+
247
+ def diff_documents(before, after)
248
+ ordered_ids(before, after).filter_map do |id|
249
+ left = before.by_id[id]
250
+ right = after.by_id[id]
251
+ next if equivalent?(left, right)
252
+
253
+ {
254
+ path: owner_path(left || right),
255
+ before: change_side(left),
256
+ after: change_side(right),
257
+ change: change_kind(left, right)
258
+ }.freeze
259
+ end.freeze
260
+ end
261
+
262
+ def ordered_ids(*documents)
263
+ documents.flat_map { |document| document.owners.map(&:id) }.uniq
264
+ end
265
+
266
+ def equivalent?(left, right)
267
+ return true if left.nil? && right.nil?
268
+ return false unless left && right
269
+
270
+ left.fingerprint == right.fingerprint
271
+ end
272
+
273
+ def change_side(owner)
274
+ if owner
275
+ { present: true, source_role: owner.role,
276
+ byte_range: [owner.start_byte, owner.end_byte] }
277
+ else
278
+ { present: false }
279
+ end
280
+ end
281
+
282
+ def change_kind(before, after)
283
+ return :added unless before
284
+ return :deleted unless after
285
+
286
+ :edited
287
+ end
288
+
289
+ def decide(documents)
290
+ changes = []
291
+ conflicts = []
292
+ choices = {}
293
+ append_unmanaged_conflict(changes, conflicts, documents)
294
+ ordered_ids(*documents.values).each do |id|
295
+ base = documents.fetch(:base).by_id[id]
296
+ ours = documents.fetch(:ours).by_id[id]
297
+ theirs = documents.fetch(:theirs).by_id[id]
298
+ ours_change = side_change_kind(base, ours)
299
+ theirs_change = side_change_kind(base, theirs)
300
+ next choices[id] = :ours if ours_change == :unchanged && theirs_change == :unchanged
301
+
302
+ change = { path: owner_path(base || ours || theirs), ours: ours_change, theirs: theirs_change }.freeze
303
+ changes << change
304
+ choice = owner_choice(base, ours, theirs)
305
+ if choice == :conflict
306
+ conflicts << conflict_for(id, base, ours, theirs, change)
307
+ else
308
+ choices[id] = choice
309
+ end
310
+ end
311
+ Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
312
+ end
313
+
314
+ def append_unmanaged_conflict(changes, conflicts, documents)
315
+ base = documents.fetch(:base).unmanaged_fingerprint
316
+ ours = documents.fetch(:ours).unmanaged_fingerprint
317
+ theirs = documents.fetch(:theirs).unmanaged_fingerprint
318
+ return if ours == theirs
319
+
320
+ change = {
321
+ path: "<unmanaged-source>",
322
+ ours: base == ours ? :unchanged : :edited,
323
+ theirs: base == theirs ? :unchanged : :edited
324
+ }.freeze
325
+ changes << change
326
+ conflicts << {
327
+ conflict_id: "html-unmanaged-#{fingerprint([base, ours, theirs])[0, 16]}",
328
+ category: :unmanaged_source_change,
329
+ path: change.fetch(:path),
330
+ owner_id: nil,
331
+ change_classification: change
332
+ }.freeze
333
+ end
334
+
335
+ def side_change_kind(base, side)
336
+ return :unchanged if equivalent?(base, side)
337
+ return :added if !base && side
338
+ return :deleted if base && !side
339
+
340
+ :edited
341
+ end
342
+
343
+ def owner_choice(base, ours, theirs)
344
+ return :ours if equivalent?(ours, theirs)
345
+ return :theirs if equivalent?(base, ours)
346
+ return :ours if equivalent?(base, theirs)
347
+ return nil if ours.nil? && theirs.nil?
348
+
349
+ :conflict
350
+ end
351
+
352
+ def conflict_for(id, base, ours, theirs, change)
353
+ {
354
+ conflict_id: "html-owner-#{id[0, 16]}",
355
+ category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
356
+ path: change.fetch(:path),
357
+ owner_id: id,
358
+ base: owner_state(base),
359
+ ours: owner_state(ours),
360
+ theirs: owner_state(theirs),
361
+ change_classification: change
362
+ }.freeze
363
+ end
364
+
365
+ def owner_state(owner)
366
+ { present: !owner.nil?, fingerprint: owner&.fingerprint, source_role: owner&.role }
367
+ end
368
+
369
+ def exact_revision_role(request)
370
+ base = request.fetch(:base_source)
371
+ ours = request.fetch(:ours_source)
372
+ theirs = request.fetch(:theirs_source)
373
+ return :ours if ours == theirs || base == theirs
374
+
375
+ :theirs if base == ours
376
+ end
377
+
378
+ def merge3_exact(request, role)
379
+ documents = %i[base ours theirs].to_h do |source_role|
380
+ [source_role, parse_exact_source(request.fetch(:"#{source_role}_source"), source_role)]
381
+ end
382
+ winner = documents.fetch(role)
383
+ return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
384
+
385
+ output = request.fetch(:"#{role}_source")
386
+ verification = verify_exact(output, winner)
387
+ return render_failure(request, verification, :exact_revision) unless verification[:semantic_match]
388
+
389
+ result(
390
+ :merge3,
391
+ request,
392
+ output: output,
393
+ diagnostics: exact_diagnostics(documents),
394
+ render_report: exact_render_report(role, output),
395
+ verification: verification.merge(base_participated: true)
396
+ )
397
+ end
398
+
399
+ def verify_exact(output, expected)
400
+ parsed = parse_exact_source(output, :output)
401
+ if provider_failure?(parsed)
402
+ return { output_reparsed: false, byte_exact: output == expected.source,
403
+ semantic_match: false }
404
+ end
405
+
406
+ {
407
+ output_reparsed: true,
408
+ byte_exact: output == expected.source,
409
+ semantic_match: parsed.analysis.document_semantics == expected.analysis.document_semantics,
410
+ ordered_semantic_verified: true,
411
+ ast_attributes_verified: true,
412
+ source_role: expected.role
413
+ }
414
+ end
415
+
416
+ def exact_diagnostics(documents)
417
+ documents.flat_map do |role, document|
418
+ if provider_failure?(document)
419
+ [{ category: :parse_error, severity: :warning, message: "#{role} parse error", blocking: false,
420
+ source_role: role }]
421
+ else
422
+ nonblocking_issues(document.analysis, role)
423
+ end
424
+ end.freeze
425
+ end
426
+
427
+ def nonblocking_issues(analysis, role)
428
+ analysis.issues.map do |issue|
429
+ issue.merge(severity: :warning, blocking: false, source_role: role).freeze
430
+ end
431
+ end
432
+
433
+ def render_composite(request, documents, decision)
434
+ ours = documents.fetch(:ours)
435
+ edits = replacement_edits(documents, decision)
436
+ insertion_edits(documents, decision).each do |edit|
437
+ return unsafe_composite_failure(request, decision, :unproven_parent_insertion) unless edit[:start_byte]
438
+
439
+ edits << edit
440
+ end
441
+ output = apply_edits(ours.source, edits)
442
+ expected = expected_owner_states(documents, decision)
443
+ verification = verify_composite(output, expected)
444
+ unless verification[:semantic_match]
445
+ return render_failure(request, verification, :exact_html_composite,
446
+ decision.changes)
447
+ end
448
+
449
+ result(
450
+ :merge3,
451
+ request,
452
+ output: output,
453
+ changes: decision.changes,
454
+ render_report: composite_render_report(edits, output),
455
+ verification: verification.merge(base_participated: true)
456
+ )
457
+ end
458
+
459
+ def replacement_edits(documents, decision)
460
+ ours = documents.fetch(:ours)
461
+ decision.choices.filter_map do |id, role|
462
+ ours_owner = ours.by_id[id]
463
+ next unless ours_owner
464
+ next if role == :ours
465
+
466
+ chosen = role && documents.fetch(role).by_id[id]
467
+ {
468
+ start_byte: ours_owner.start_byte,
469
+ end_byte: ours_owner.end_byte,
470
+ content: chosen&.source_text.to_s,
471
+ source_role: role,
472
+ owner_id: id,
473
+ action: chosen ? :replace : :delete
474
+ }
475
+ end
476
+ end
477
+
478
+ def insertion_edits(documents, decision)
479
+ ours = documents.fetch(:ours)
480
+ additions = documents.fetch(:theirs).owners.select do |owner|
481
+ !ours.by_id.key?(owner.id) && decision.choices[owner.id] == :theirs
482
+ end
483
+ additions.group_by(&:parent_key).map do |parent_key, owners|
484
+ {
485
+ start_byte: ours.parents[parent_key],
486
+ end_byte: ours.parents[parent_key],
487
+ content: owners.map(&:source_text).join,
488
+ source_role: :theirs,
489
+ owner_id: owners.map(&:id),
490
+ action: :insert
491
+ }
492
+ end
493
+ end
494
+
495
+ def apply_edits(source, edits)
496
+ output = source.dup
497
+ edits.sort_by { |edit| [edit.fetch(:start_byte), edit.fetch(:end_byte)] }.reverse_each do |edit|
498
+ output[edit.fetch(:start_byte)...edit.fetch(:end_byte)] = edit.fetch(:content)
499
+ end
500
+ output
501
+ end
502
+
503
+ def expected_owner_states(documents, decision)
504
+ states = decision.choices.filter_map do |id, role|
505
+ owner = role && documents.fetch(role).by_id[id]
506
+ owner && [owner.signature, owner.fingerprint]
507
+ end
508
+ states.sort_by { |value| JSON.generate(Ast::Merge.json_ready(value.first)) }
509
+ end
510
+
511
+ def verify_composite(output, expected)
512
+ parsed = parse_source(output, :output)
513
+ if provider_failure?(parsed)
514
+ return { output_reparsed: false, semantic_match: false, parse_error: failure_detail(parsed) }
515
+ end
516
+
517
+ actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
518
+ .sort_by { |value| JSON.generate(Ast::Merge.json_ready(value.first)) }
519
+ {
520
+ output_reparsed: true,
521
+ semantic_match: actual == expected,
522
+ ordered_semantic_verified: true,
523
+ ast_attributes_verified: actual == expected,
524
+ planned_owner_count: expected.length,
525
+ output_owner_count: actual.length
526
+ }
527
+ end
528
+
529
+ def render_conflicts(request, documents, decision)
530
+ localized = localized_conflict_output(request, documents, decision)
531
+ return conflict_failure(request, decision, localized, :element_localized_conflict) if localized
532
+
533
+ conflict_failure(request, decision, whole_document_conflict(request), :full_file_conflict,
534
+ [{ from: :element_localization, to: :full_file_conflict,
535
+ reason: :source_ownership_unproven }])
536
+ end
537
+
538
+ def localized_conflict_output(request, documents, decision)
539
+ return if decision.conflicts.any? { |conflict| conflict[:owner_id].nil? }
540
+
541
+ ours = documents.fetch(:ours)
542
+ return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
543
+
544
+ edits = decision.conflicts.map do |conflict|
545
+ owner = ours.by_id.fetch(conflict.fetch(:owner_id))
546
+ {
547
+ start_byte: owner.start_byte,
548
+ end_byte: owner.end_byte,
549
+ content: conflict_text(request, documents, conflict)
550
+ }
551
+ end
552
+ apply_edits(ours.source, edits)
553
+ end
554
+
555
+ def conflict_text(request, documents, conflict)
556
+ marker = request.fetch(:conflict_marker_size, 7).to_i
557
+ marker = 7 unless marker.positive?
558
+ labels = { ours: "ours", base: "base", theirs: "theirs" }.merge(request.fetch(:labels, {}))
559
+ sides = %i[ours base theirs].to_h do |role|
560
+ owner = documents.fetch(role).by_id[conflict.fetch(:owner_id)]
561
+ [role, owner&.source_text.to_s]
562
+ end
563
+ [
564
+ "#{"<" * marker} #{labels[:ours]}\n", sides[:ours],
565
+ "#{"|" * marker} #{labels[:base]}\n", sides[:base],
566
+ "#{"=" * marker}\n", sides[:theirs],
567
+ "#{">" * marker} #{labels[:theirs]}\n"
568
+ ].join
569
+ end
570
+
571
+ def whole_document_conflict(request)
572
+ marker = request.fetch(:conflict_marker_size, 7).to_i
573
+ marker = 7 unless marker.positive?
574
+ [
575
+ "#{"<" * marker} ours\n", request.fetch(:ours_source),
576
+ "#{"|" * marker} base\n", request.fetch(:base_source),
577
+ "#{"=" * marker}\n", request.fetch(:theirs_source),
578
+ "#{">" * marker} theirs\n"
579
+ ].join
580
+ end
581
+
582
+ def conflict_failure(request, decision, output, strategy, fallbacks = [])
583
+ failure(
584
+ :merge3,
585
+ request,
586
+ category: :merge_conflict,
587
+ message: "HTML owner changed incompatibly on both sides.",
588
+ changes: decision.changes,
589
+ conflicts: decision.conflicts,
590
+ conflicted_output: output,
591
+ fallbacks: fallbacks,
592
+ render_report: { strategy: strategy, provenance: :exact_source_bytes },
593
+ verification: { base_participated: true }
594
+ )
595
+ end
596
+
597
+ def unsafe_document_failure(request, role, parsed)
598
+ return parse_failure(:merge3, request, role, parsed) if parsed[:parse_error]
599
+
600
+ category = parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range
601
+ conflict = {
602
+ conflict_id: "html-unsafe-#{fingerprint([role, failure_detail(parsed)])[0, 16]}",
603
+ category: category,
604
+ path: "<document>",
605
+ owner_id: nil,
606
+ source_role: role
607
+ }
608
+ failure(
609
+ :merge3,
610
+ request,
611
+ category: category,
612
+ message: "#{role} HTML ownership is unsafe: #{failure_detail(parsed)}",
613
+ conflicts: [conflict],
614
+ conflicted_output: whole_document_conflict(request),
615
+ source_role: role,
616
+ render_report: { strategy: :full_file_conflict, provenance: :exact_source_bytes },
617
+ verification: { base_participated: true }
618
+ )
619
+ end
620
+
621
+ def unsafe_composite_failure(request, decision, reason)
622
+ failure(
623
+ :merge3,
624
+ request,
625
+ category: :unsafe_source_range,
626
+ message: "HTML composite insertion has no parser-proven parent closing boundary.",
627
+ changes: decision.changes,
628
+ conflicts: [{ conflict_id: "html-parent-#{reason}", category: reason, path: "<document>", owner_id: nil }],
629
+ conflicted_output: whole_document_conflict(request),
630
+ fallbacks: [{ from: :element_composite, to: :full_file_conflict, reason: reason }],
631
+ render_report: { strategy: :full_file_conflict, provenance: :exact_source_bytes },
632
+ verification: { base_participated: true }
633
+ )
634
+ end
635
+
636
+ def render_failure(request, verification, strategy, changes = [])
637
+ failure(
638
+ :merge3,
639
+ request,
640
+ category: :render_failure,
641
+ message: "HTML output failed native parser and ordered semantic verification.",
642
+ changes: changes,
643
+ render_report: { strategy: strategy },
644
+ verification: verification.merge(base_participated: true)
645
+ )
646
+ end
647
+
648
+ def exact_render_report(role, output)
649
+ {
650
+ strategy: :exact_revision,
651
+ provenance: [{ source_role: role, byte_range: [0, output.bytesize], copied_source: true }],
652
+ synthesized_fragments: []
653
+ }
654
+ end
655
+
656
+ def composite_render_report(edits, output)
657
+ {
658
+ strategy: :exact_html_composite,
659
+ provenance: edits.map { |edit| edit.slice(:source_role, :owner_id, :action, :start_byte, :end_byte) },
660
+ output_bytes: output.bytesize,
661
+ synthesized_fragments: []
662
+ }
663
+ end
664
+
665
+ def owner_path(owner)
666
+ owner ? owner.signature.inspect : "<document>"
667
+ end
668
+
669
+ def owner_description(owner)
670
+ {
671
+ path: owner_path(owner),
672
+ signature: owner.signature,
673
+ source_role: owner.role,
674
+ byte_range: [owner.start_byte, owner.end_byte],
675
+ parent: owner.parent_key
676
+ }
677
+ end
678
+
679
+ def parse_failure(operation, request, role, parsed)
680
+ category = if parsed[:parse_error]
681
+ :parse_error
682
+ else
683
+ parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range
684
+ end
685
+ failure(
686
+ operation,
687
+ request,
688
+ category: category,
689
+ message: "#{role} parse error: #{failure_detail(parsed)}",
690
+ source_role: role
691
+ )
692
+ end
693
+
694
+ def failure_detail(parsed)
695
+ return parsed[:parse_error] if parsed[:parse_error]
696
+ return parsed[:ambiguous_owner].inspect if parsed[:ambiguous_owner]
697
+
698
+ parsed[:unsafe_range].inspect
699
+ end
700
+
701
+ def provider_failure?(value)
702
+ value.is_a?(Hash) &&
703
+ (value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous_owner) || value.key?(:unsafe_range))
704
+ end
705
+
706
+ def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
707
+ Ast::Merge::ProviderResult.build(
708
+ operation: operation,
709
+ success: true,
710
+ envelope: envelope(
711
+ request,
712
+ changes: changes,
713
+ diagnostics: diagnostics,
714
+ render_report: render_report,
715
+ verification: verification
716
+ ),
717
+ **payload
718
+ )
719
+ end
720
+
721
+ def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
722
+ render_report: {}, verification: {}, **payload)
723
+ Ast::Merge::ProviderResult.build(
724
+ operation: operation,
725
+ success: false,
726
+ envelope: envelope(
727
+ request,
728
+ changes: changes,
729
+ conflicts: conflicts,
730
+ fallbacks: fallbacks,
731
+ diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
732
+ render_report: render_report,
733
+ verification: verification
734
+ ),
735
+ **payload
736
+ )
737
+ end
738
+
739
+ def envelope(request, **fields)
740
+ {
741
+ provider: {
742
+ provider_id: provider_id,
743
+ family: family,
744
+ dialect: request[:dialect] || :html,
745
+ backend: request[:backend] || BACKEND_REFERENCE.id.to_sym,
746
+ package: "html-merge",
747
+ package_version: Html::Merge::Version::VERSION
748
+ },
749
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
750
+ }.merge(fields)
751
+ end
752
+ end
753
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
754
+ end
755
+ end
@@ -5,7 +5,7 @@ module Html
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "7.1.1"
8
+ VERSION = "7.1.3"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/html/merge.rb CHANGED
@@ -16,6 +16,9 @@ module Html
16
16
  class ParseError < Ast::Merge::ParseError; end
17
17
 
18
18
  autoload :CrisprAdapter, "html/merge/crispr_adapter"
19
+ autoload :NodeWrapper, "html/merge/node_wrapper"
20
+ autoload :FileAnalysis, "html/merge/file_analysis"
21
+ autoload :Provider, "html/merge/provider"
19
22
 
20
23
  module_function
21
24
 
@@ -249,9 +252,22 @@ module Html
249
252
 
250
253
  actor.updated_content
251
254
  end
255
+
256
+ def merge_provider
257
+ @merge_provider ||= Provider.new
258
+ end
259
+
260
+ def register_provider!(replace: false)
261
+ return unless Ast::Merge.respond_to?(:register_provider)
262
+
263
+ Ast::Merge.register_provider(merge_provider, replace: replace)
264
+ end
252
265
  end
253
266
  end
254
267
 
255
268
  Html::Merge::Version.class_eval do
256
269
  extend VersionGem::Basic
257
270
  end
271
+
272
+ Html::Merge.register_backend!
273
+ Html::Merge.register_provider!
data/sig/html/merge.rbs CHANGED
@@ -5,6 +5,47 @@ module Html
5
5
  VERSION: String
6
6
  end
7
7
 
8
+ class NodeWrapper
9
+ ELEMENT_TYPES: Array[String]
10
+ attr_reader node: untyped
11
+ attr_reader source: String
12
+ attr_reader parent: NodeWrapper?
13
+ def initialize: (untyped node, source: String, ?parent: NodeWrapper?) -> void
14
+ def element?: () -> bool
15
+ def start_byte: () -> Integer
16
+ def end_byte: () -> Integer
17
+ def source_text: () -> String
18
+ def tag_name: () -> String?
19
+ def explicit_end_tag?: () -> bool
20
+ def self_closing?: () -> bool
21
+ def attributes: () -> Hash[String, String | true]
22
+ def explicit_id: () -> (String | true)?
23
+ def semantic_tree: () -> untyped
24
+ end
25
+
26
+ class FileAnalysis
27
+ attr_reader source: String
28
+ attr_reader root_node: untyped
29
+ attr_reader wrappers: Array[NodeWrapper]
30
+ attr_reader errors: Array[untyped]
31
+ attr_reader backend: Symbol
32
+ attr_reader document_semantics: untyped
33
+ attr_reader issues: Array[Hash[Symbol, untyped]]
34
+ def initialize: (String source) -> void
35
+ def valid?: () -> bool
36
+ end
37
+
38
+ class Provider
39
+ DEFAULT_PROFILE: Symbol
40
+ def provider_id: () -> String
41
+ def family: () -> String
42
+ def capabilities: () -> Hash[Symbol, untyped]
43
+ def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
44
+ def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
45
+ def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
46
+ def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
47
+ end
48
+
8
49
  def self.register_backend!: () -> nil
9
50
  def self.html_feature_profile: () -> Hash[Symbol, untyped]
10
51
  def self.available_html_backends: () -> Array[untyped]
@@ -14,5 +55,7 @@ module Html
14
55
  def self.document_context: (content: String, ?source_label: String) -> untyped
15
56
  def self.element_text_selector: (text: String, ?id: String?) -> untyped
16
57
  def self.ensure_yard_content_wrapper: (String source) -> String
58
+ def self.merge_provider: () -> Provider
59
+ def self.register_provider!: (?replace: bool) -> untyped
17
60
  end
18
61
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.1
4
+ version: 7.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -43,42 +43,42 @@ dependencies:
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 7.1.1
46
+ version: 7.1.3
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 7.1.1
53
+ version: 7.1.3
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: ast-merge
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: 7.1.1
60
+ version: 7.1.3
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - '='
66
66
  - !ruby/object:Gem::Version
67
- version: 7.1.1
67
+ version: 7.1.3
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: tree_haver
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: 7.1.1
74
+ version: 7.1.3
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - '='
80
80
  - !ruby/object:Gem::Version
81
- version: 7.1.1
81
+ version: 7.1.3
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: version_gem
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -105,20 +105,20 @@ dependencies:
105
105
  requirements:
106
106
  - - "~>"
107
107
  - !ruby/object:Gem::Version
108
- version: '2.5'
108
+ version: '3.0'
109
109
  - - ">="
110
110
  - !ruby/object:Gem::Version
111
- version: 2.5.24
111
+ version: 3.0.0
112
112
  type: :development
113
113
  prerelease: false
114
114
  version_requirements: !ruby/object:Gem::Requirement
115
115
  requirements:
116
116
  - - "~>"
117
117
  - !ruby/object:Gem::Version
118
- version: '2.5'
118
+ version: '3.0'
119
119
  - - ">="
120
120
  - !ruby/object:Gem::Version
121
- version: 2.5.24
121
+ version: 3.0.0
122
122
  - !ruby/object:Gem::Dependency
123
123
  name: bundler-audit
124
124
  requirement: !ruby/object:Gem::Requirement
@@ -236,7 +236,7 @@ dependencies:
236
236
  version: '3.2'
237
237
  - - ">="
238
238
  - !ruby/object:Gem::Version
239
- version: 3.2.4
239
+ version: 3.2.5
240
240
  type: :development
241
241
  prerelease: false
242
242
  version_requirements: !ruby/object:Gem::Requirement
@@ -246,7 +246,7 @@ dependencies:
246
246
  version: '3.2'
247
247
  - - ">="
248
248
  - !ruby/object:Gem::Version
249
- version: 3.2.4
249
+ version: 3.2.5
250
250
  - !ruby/object:Gem::Dependency
251
251
  name: ruby-progressbar
252
252
  requirement: !ruby/object:Gem::Requirement
@@ -313,6 +313,9 @@ files:
313
313
  - README.md
314
314
  - lib/html/merge.rb
315
315
  - lib/html/merge/crispr_adapter.rb
316
+ - lib/html/merge/file_analysis.rb
317
+ - lib/html/merge/node_wrapper.rb
318
+ - lib/html/merge/provider.rb
316
319
  - lib/html/merge/version.rb
317
320
  - sig/html/merge.rbs
318
321
  homepage: https://github.com/structuredmerge/structuredmerge-ruby
@@ -320,10 +323,10 @@ licenses:
320
323
  - MIT
321
324
  metadata:
322
325
  homepage_uri: https://structuredmerge.org
323
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.1
324
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.1/CHANGELOG.md
326
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
327
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
325
328
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
326
- documentation_uri: https://www.rubydoc.info/gems/html-merge/7.1.1
329
+ documentation_uri: https://www.rubydoc.info/gems/html-merge/7.1.3
327
330
  funding_uri: https://github.com/sponsors/pboling
328
331
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
329
332
  news_uri: https://www.railsbling.com/tags/html-merge
metadata.gz.sig CHANGED
Binary file