markdown-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 +2 -10
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/version.rb +1 -1
- data/lib/markdown/merge.rb +38 -0
- data/sig/markdown/merge.rbs +54 -0
- data.tar.gz.sig +0 -0
- metadata +18 -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: 1afd41c13e6a3e5aa744808aa85ba5f3fdca99f3f91857532937198c5dc0faf2
|
|
4
|
+
data.tar.gz: 9256d7276aad3542710630327e12ba5bc52674b8b9eb59343c30488052512a30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9772eb8ab0cc215b31ea7a993cb058103db753f0a8030acbcb095fe16d60cfb779d7d4dffdbb032a56a0feb5be30e7d489013b41f9c34f41ee739af277268785
|
|
7
|
+
data.tar.gz: b7a0dbf6a1a9ad9d030a44260c097da5129cbb6332cf86dff7c0cd9c384f8611bb54e546267ec932dc7f722a764a8e1c60494501d1e58103b6e1a8e635c75fe7
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Signatures computed by default for common Markdown block elements:
|
|
|
94
94
|
|
|
95
95
|
### Compatibility
|
|
96
96
|
|
|
97
|
-
Compatible with MRI Ruby 4.0.0+,
|
|
97
|
+
Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
|
|
98
98
|
CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
|
|
99
99
|
This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
|
|
100
100
|
may be higher than the gem's runtime compatibility floor when legacy Rubies are
|
|
@@ -506,20 +506,12 @@ See [SECURITY.md][🔐security].
|
|
|
506
506
|
## 🤝 Contributing
|
|
507
507
|
|
|
508
508
|
If you need some ideas of where to help, you could work on adding more code coverage,
|
|
509
|
-
|
|
510
|
-
or use the gem and think about how it could be better.
|
|
509
|
+
check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
|
|
511
510
|
|
|
512
511
|
We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
|
|
513
512
|
|
|
514
513
|
See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
|
|
515
514
|
|
|
516
|
-
### Code Coverage
|
|
517
|
-
|
|
518
|
-
<details markdown="1">
|
|
519
|
-
<summary>Coverage service badges</summary>
|
|
520
|
-
|
|
521
|
-
</details>
|
|
522
|
-
|
|
523
515
|
## 📌 Versioning
|
|
524
516
|
|
|
525
517
|
This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'ast/merge/rspec/shared_examples'
|
|
5
|
+
|
|
6
|
+
# rubocop:disable Metrics/BlockLength -- the conservative provider safety contract is intentionally executable as one matrix
|
|
7
|
+
RSpec.shared_examples 'Markdown::Merge::SourcePreservingProvider' do
|
|
8
|
+
let(:base) { "# Alpha\n\nalpha\n\n# Beta\n\nbeta\n" }
|
|
9
|
+
let(:stable) { "# Stable\n\nstable\n" }
|
|
10
|
+
let(:provider_conformance) do
|
|
11
|
+
{
|
|
12
|
+
dialect: provider_dialect,
|
|
13
|
+
backend: provider_backend,
|
|
14
|
+
profile_id: :source_preserving,
|
|
15
|
+
role: provider_role,
|
|
16
|
+
requests: {
|
|
17
|
+
analyze: { source: stable },
|
|
18
|
+
diff2: { before_source: stable, after_source: stable.sub('stable', 'changed') },
|
|
19
|
+
merge2: { current_source: stable, incoming_source: "#{stable}# Added\n\nadded\n" },
|
|
20
|
+
merge3: {
|
|
21
|
+
base_source: base,
|
|
22
|
+
ours_source: base.sub('alpha', 'ours'),
|
|
23
|
+
theirs_source: base.sub('beta', 'theirs')
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
invalid_merge3: {
|
|
27
|
+
base_source: stable,
|
|
28
|
+
ours_source: "# Broken\0\n",
|
|
29
|
+
theirs_source: stable,
|
|
30
|
+
source_role: :ours
|
|
31
|
+
},
|
|
32
|
+
base_adversarial_merge3: {
|
|
33
|
+
request: {
|
|
34
|
+
base_source: "# Obsolete\n\nold\n\n#{stable}",
|
|
35
|
+
ours_source: "# Obsolete\n\nold\n\n#{stable}",
|
|
36
|
+
theirs_source: stable
|
|
37
|
+
},
|
|
38
|
+
expected_value: [[1, 'Stable']]
|
|
39
|
+
},
|
|
40
|
+
parse_output: lambda { |source|
|
|
41
|
+
provider.analyze(source: source).dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'passes the shared provider contract for every operation and exact selector' do
|
|
47
|
+
Ast::Merge.register_provider(provider, replace: true)
|
|
48
|
+
registration = Ast::Merge::ProviderContract.validate_provider!(provider)
|
|
49
|
+
selectors = {
|
|
50
|
+
provider_id: provider.provider_id,
|
|
51
|
+
family: provider.family,
|
|
52
|
+
dialect: provider_dialect,
|
|
53
|
+
backend: provider_backend,
|
|
54
|
+
profile_id: :source_preserving
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(registration.dig(:capabilities, :role)).to eq(provider_role)
|
|
58
|
+
expect(Ast::Merge.resolve_provider(**selectors, operation: :merge3)).to equal(provider)
|
|
59
|
+
provider_conformance.fetch(:requests).each do |operation, request|
|
|
60
|
+
result = Ast::Merge.dispatch_provider(operation, selectors.merge(request))
|
|
61
|
+
expect(Ast::Merge::ProviderContract.validate_result!(operation, result)).to include(ok: true)
|
|
62
|
+
end
|
|
63
|
+
unsupported = Ast::Merge.dispatch_provider(
|
|
64
|
+
:merge3,
|
|
65
|
+
selectors.merge(provider_conformance.dig(:requests, :merge3), dialect: :unadvertised)
|
|
66
|
+
)
|
|
67
|
+
expect(unsupported).to include(
|
|
68
|
+
ok: false,
|
|
69
|
+
diagnostics: contain_exactly(hash_including(category: :unsupported_capability))
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'advertises and resolves its exact selector' do
|
|
74
|
+
expect(provider.capabilities).to include(
|
|
75
|
+
operations: %i[analyze diff2 merge2 merge3],
|
|
76
|
+
dialects: include(provider_dialect),
|
|
77
|
+
backends: [provider_backend],
|
|
78
|
+
profiles: %i[source_preserving],
|
|
79
|
+
role: provider_role
|
|
80
|
+
)
|
|
81
|
+
expect(
|
|
82
|
+
Ast::Merge.resolve_provider(
|
|
83
|
+
provider_id: provider.provider_id,
|
|
84
|
+
family: :markdown,
|
|
85
|
+
dialect: provider_dialect,
|
|
86
|
+
backend: provider_backend,
|
|
87
|
+
profile_id: :source_preserving,
|
|
88
|
+
operation: :merge3
|
|
89
|
+
)
|
|
90
|
+
).to equal(provider)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'analyzes unique parser-proven heading identities and emits JSON-ready results' do
|
|
94
|
+
result = provider.analyze(source: base)
|
|
95
|
+
|
|
96
|
+
expect(result).to include(ok: true)
|
|
97
|
+
expect(result.dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }).to eq(
|
|
98
|
+
[[1, 'Alpha'], [1, 'Beta']]
|
|
99
|
+
)
|
|
100
|
+
expect { JSON.generate(Ast::Merge.json_ready(result)) }.not_to raise_error
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'matches the shared safe-subset semantics at its explicit backend boundary' do
|
|
104
|
+
result = provider.analyze(source: "# One\n\nbody\n\n# Two\n\nbody\n")
|
|
105
|
+
|
|
106
|
+
expect(result.dig(:analysis, :backend)).to eq(provider_backend)
|
|
107
|
+
expect(result.dig(:analysis, :delegated_backend)).to eq(provider_backend)
|
|
108
|
+
expect(result.dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }).to eq(
|
|
109
|
+
[[1, 'One'], [1, 'Two']]
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'diffs and merges independent section edits, additions, and deletions' do
|
|
114
|
+
ours = base.sub('alpha', 'ours alpha')
|
|
115
|
+
theirs = base.sub("# Beta\n\nbeta\n", '')
|
|
116
|
+
deletion = provider.merge3(base_source: base, ours_source: ours, theirs_source: theirs)
|
|
117
|
+
|
|
118
|
+
addition_base = "#{base}\n"
|
|
119
|
+
ours_addition = "#{addition_base}# Ours\n\nours addition\n\n"
|
|
120
|
+
theirs_addition = "#{addition_base}# Theirs\n\ntheirs addition\n\n"
|
|
121
|
+
additions = provider.merge3(
|
|
122
|
+
base_source: addition_base,
|
|
123
|
+
ours_source: ours_addition,
|
|
124
|
+
theirs_source: theirs_addition
|
|
125
|
+
)
|
|
126
|
+
edit_diff = provider.diff2(before_source: base, after_source: ours)
|
|
127
|
+
addition_diff = provider.diff2(before_source: addition_base, after_source: ours_addition)
|
|
128
|
+
|
|
129
|
+
expect(edit_diff.fetch(:changes).map { |change| change.fetch(:change) }).to contain_exactly(:edited)
|
|
130
|
+
expect(addition_diff.fetch(:changes).map { |change| change.fetch(:change) }).to contain_exactly(:added)
|
|
131
|
+
expect(deletion).to include(ok: true)
|
|
132
|
+
expect(deletion.fetch(:output)).to include('ours alpha')
|
|
133
|
+
expect(deletion.fetch(:output)).not_to include('# Beta')
|
|
134
|
+
expect(additions).to include(ok: true)
|
|
135
|
+
expect(additions.fetch(:output)).to include('# Ours', '# Theirs')
|
|
136
|
+
expect(additions.fetch(:verification)).to include(
|
|
137
|
+
base_participated: true,
|
|
138
|
+
output_reparsed: true,
|
|
139
|
+
semantic_match: true,
|
|
140
|
+
byte_provenance_verified: true,
|
|
141
|
+
delegated_backend_verified: true,
|
|
142
|
+
backend: provider_backend
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'localizes a same-section conflict to the parser-proven owner range' do
|
|
147
|
+
result = provider.merge3(
|
|
148
|
+
base_source: base,
|
|
149
|
+
ours_source: base.sub('alpha', 'ours'),
|
|
150
|
+
theirs_source: base.sub('alpha', 'theirs')
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
expect(result).to include(ok: false)
|
|
154
|
+
expect(result.fetch(:conflicts)).to contain_exactly(hash_including(category: :edit_edit))
|
|
155
|
+
expect(result.dig(:render_report, :strategy)).to eq(:section_localized_conflict)
|
|
156
|
+
expect(result.fetch(:conflicted_output)).to include('# Beta', 'beta')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'fails closed for duplicate, renamed, level-changed, nested, and headingless ownership' do
|
|
160
|
+
duplicate = "# Alpha\n\none\n\n# Alpha\n\ntwo\n"
|
|
161
|
+
expect(provider.analyze(source: duplicate)).to include(
|
|
162
|
+
ok: false,
|
|
163
|
+
diagnostics: include(hash_including(category: :ambiguous_owner))
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
rename: base.sub('# Alpha', '# Renamed'),
|
|
168
|
+
rename_with_body_edit: base.sub("# Alpha\n\nalpha", "# Renamed\n\nrewritten"),
|
|
169
|
+
level: base.sub('# Alpha', '## Alpha')
|
|
170
|
+
}.each_value do |ours|
|
|
171
|
+
result = provider.merge3(base_source: base, ours_source: ours, theirs_source: base.sub('beta', 'theirs'))
|
|
172
|
+
expect(result).to include(ok: false)
|
|
173
|
+
expect(result.fetch(:diagnostics)).to include(hash_including(blocking: true))
|
|
174
|
+
expect(result.dig(:render_report, :strategy)).to eq(:full_file_conflict)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
expect(provider.analyze(source: "# Alpha\n\n## Nested\n\ntext\n")).to include(
|
|
178
|
+
ok: false,
|
|
179
|
+
diagnostics: include(hash_including(category: :unsafe_source_range))
|
|
180
|
+
)
|
|
181
|
+
expect(provider.analyze(source: "headingless prose\n")).to include(
|
|
182
|
+
ok: false,
|
|
183
|
+
diagnostics: include(hash_including(category: :unsafe_source_range))
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'fails closed for setext headings and non-whitespace prose outside sections' do
|
|
188
|
+
expect(provider.analyze(source: "Alpha\n=====\n\ntext\n")).to include(
|
|
189
|
+
ok: false,
|
|
190
|
+
diagnostics: include(hash_including(category: :unsafe_source_range))
|
|
191
|
+
)
|
|
192
|
+
expect(provider.analyze(source: "preamble\n\n# Alpha\n\ntext\n")).to include(
|
|
193
|
+
ok: false,
|
|
194
|
+
diagnostics: include(hash_including(category: :unsafe_source_range))
|
|
195
|
+
)
|
|
196
|
+
expect(provider.analyze(source: "\n# Alpha\n\ntext\n")).to include(
|
|
197
|
+
ok: false,
|
|
198
|
+
diagnostics: include(hash_including(category: :unsafe_source_range))
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'does not promote headings nested inside block containers to document section owners' do
|
|
203
|
+
quoted = "# Alpha\n\n> # Quoted\n> body\n\n# Beta\n\nbeta\n"
|
|
204
|
+
result = provider.analyze(source: quoted)
|
|
205
|
+
|
|
206
|
+
expect(result).to include(ok: true)
|
|
207
|
+
expect(result.dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }).to eq(
|
|
208
|
+
[[1, 'Alpha'], [1, 'Beta']]
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'cleanly composes independent deletions that remove every owned section' do
|
|
213
|
+
ours = "# Beta\n\nbeta\n"
|
|
214
|
+
theirs = "# Alpha\n\nalpha\n\n"
|
|
215
|
+
result = provider.merge3(base_source: base, ours_source: ours, theirs_source: theirs)
|
|
216
|
+
|
|
217
|
+
expect(result).to include(ok: true, output: '')
|
|
218
|
+
expect(result.fetch(:verification)).to include(
|
|
219
|
+
output_reparsed: true,
|
|
220
|
+
semantic_match: true,
|
|
221
|
+
planned_owner_count: 0,
|
|
222
|
+
output_owner_count: 0
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'exactly preserves complex Markdown and a missing final newline for one-sided winners' do
|
|
227
|
+
exact = <<~'MARKDOWN'.chomp.sub('<HARD_BREAK>', ' ')
|
|
228
|
+
---
|
|
229
|
+
title: Exact
|
|
230
|
+
---
|
|
231
|
+
# Alpha
|
|
232
|
+
|
|
233
|
+
- list
|
|
234
|
+
- item
|
|
235
|
+
3. numbered
|
|
236
|
+
- [x] task
|
|
237
|
+
|
|
238
|
+
> quoted<HARD_BREAK>
|
|
239
|
+
> hard break & \*escaped\*
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
puts "# not a heading"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
| a | b |
|
|
246
|
+
|---|---|
|
|
247
|
+
| 1 | 2 |
|
|
248
|
+
|
|
249
|
+
<div data-x='1'>inline HTML</div>
|
|
250
|
+
|
|
251
|
+
[ref]: https://example.test "Title"
|
|
252
|
+
[use][ref]
|
|
253
|
+
|
|
254
|
+
Footnote[^1].
|
|
255
|
+
[^1]: exact footnote
|
|
256
|
+
|
|
257
|
+
:::note
|
|
258
|
+
extension directive
|
|
259
|
+
:::
|
|
260
|
+
MARKDOWN
|
|
261
|
+
result = provider.merge3(base_source: base, ours_source: base, theirs_source: exact)
|
|
262
|
+
|
|
263
|
+
expect(result).to include(ok: true, output: exact)
|
|
264
|
+
expect(result.dig(:render_report, :strategy)).to eq(:exact_revision)
|
|
265
|
+
expect(result.fetch(:verification)).to include(byte_exact: true, semantic_match: true, base_participated: true)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it 'copies complex independently selected section fragments byte-for-byte in a composite' do
|
|
269
|
+
alpha = <<~'MARKDOWN'
|
|
270
|
+
# Alpha
|
|
271
|
+
|
|
272
|
+
- [ ] task
|
|
273
|
+
7. numbered
|
|
274
|
+
|
|
275
|
+
> quoted
|
|
276
|
+
|
|
277
|
+
~~~~ruby extra
|
|
278
|
+
puts "&"
|
|
279
|
+
~~~~
|
|
280
|
+
|
|
281
|
+
| a | b |
|
|
282
|
+
|---|---|
|
|
283
|
+
| 1 | 2 |
|
|
284
|
+
|
|
285
|
+
<span data-x='1'>html</span>
|
|
286
|
+
|
|
287
|
+
[ref]: https://example.test
|
|
288
|
+
[use][ref] and footnote[^1].
|
|
289
|
+
[^1]: exact
|
|
290
|
+
MARKDOWN
|
|
291
|
+
beta = "# Beta\n\nbeta\n"
|
|
292
|
+
composite_base = "#{alpha}\n#{beta}"
|
|
293
|
+
ours_alpha = alpha.sub('- [ ] task', '- [x] task')
|
|
294
|
+
theirs_beta = beta.sub('beta', 'theirs')
|
|
295
|
+
result = provider.merge3(
|
|
296
|
+
base_source: composite_base,
|
|
297
|
+
ours_source: "#{ours_alpha}\n#{beta}",
|
|
298
|
+
theirs_source: "#{alpha}\n#{theirs_beta}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
expect(result).to include(ok: true, output: "#{ours_alpha}\n#{theirs_beta}")
|
|
302
|
+
expect(result.dig(:render_report, :provenance)).to all(include(copied_source: true))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it 'accepts parser-valid ambiguous exact winners with nonblocking diagnostics' do
|
|
306
|
+
duplicate = "# Same\n\none\n\n# Same\n\ntwo\n"
|
|
307
|
+
result = provider.merge3(base_source: base, ours_source: base, theirs_source: duplicate)
|
|
308
|
+
|
|
309
|
+
expect(result).to include(ok: true, output: duplicate)
|
|
310
|
+
expect(result.fetch(:diagnostics)).to include(
|
|
311
|
+
hash_including(category: :ambiguous_owner, blocking: false, source_role: :theirs)
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'rejects malformed exact winners with selected-role attribution' do
|
|
316
|
+
malformed = "# Broken\0\n"
|
|
317
|
+
result = provider.merge3(base_source: base, ours_source: base, theirs_source: malformed)
|
|
318
|
+
|
|
319
|
+
expect(result).to include(ok: false, source_role: :theirs)
|
|
320
|
+
expect(result.fetch(:diagnostics)).to contain_exactly(
|
|
321
|
+
hash_including(category: :parse_error, blocking: true)
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'uses the base in adversarial deletion merges' do
|
|
326
|
+
old_base = "# Obsolete\n\nold\n\n#{base}"
|
|
327
|
+
result = provider.merge3(base_source: old_base, ours_source: old_base, theirs_source: base)
|
|
328
|
+
|
|
329
|
+
expect(result).to include(ok: true, output: base)
|
|
330
|
+
expect(result.fetch(:output)).not_to include('Obsolete')
|
|
331
|
+
expect(result.dig(:verification, :base_participated)).to be(true)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it 'preserves standalone and legacy APIs' do
|
|
335
|
+
expect(legacy_assertion.call).to be(true)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
# rubocop:enable Metrics/BlockLength
|