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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +24 -11
- data/lib/html/merge/file_analysis.rb +104 -0
- data/lib/html/merge/node_wrapper.rb +80 -0
- data/lib/html/merge/provider.rb +755 -0
- data/lib/html/merge/version.rb +1 -1
- data/lib/html/merge.rb +16 -0
- data/sig/html/merge.rbs +43 -0
- data.tar.gz.sig +0 -0
- metadata +19 -16
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d1bb35ada60501d9b53cbe2ea82be771de26ad1df6b362625a4fbd2021588a8
|
|
4
|
+
data.tar.gz: 859dfb00db8875f6cbf69896c8a7a5f3ac09c6a3af6ced496917b739152baae6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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+,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/html/merge/version.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: '
|
|
108
|
+
version: '3.0'
|
|
109
109
|
- - ">="
|
|
110
110
|
- !ruby/object:Gem::Version
|
|
111
|
-
version:
|
|
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: '
|
|
118
|
+
version: '3.0'
|
|
119
119
|
- - ">="
|
|
120
120
|
- !ruby/object:Gem::Version
|
|
121
|
-
version:
|
|
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.
|
|
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.
|
|
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.
|
|
324
|
-
changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.
|
|
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.
|
|
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
|