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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2458767c252448eda3b23fa8a8775c63dd583883545a92cb933b65464db6ed7d
4
- data.tar.gz: 380f20879d0bdf46c9004438f6c1e25a6f2b83b201f2976e82da4f354a7ea307
3
+ metadata.gz: 1afd41c13e6a3e5aa744808aa85ba5f3fdca99f3f91857532937198c5dc0faf2
4
+ data.tar.gz: 9256d7276aad3542710630327e12ba5bc52674b8b9eb59343c30488052512a30
5
5
  SHA512:
6
- metadata.gz: dcd5dd32fa9aa23add3c2275294dbd0179ed94d9385bacbf9afc30388d8fd5f456a8b9b048a8c4b555524a6a14e644d635d06693f5db4952d760c4b3f65d1d31
7
- data.tar.gz: 1334694a7faebeb66116d5e9dfe279368f4936221432014e4153e81723f3977c060f518a13a9c1bc8d1892eb1831bfc85173702877c6ba8429d5a37e6df41d10
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+, and concordant releases of JRuby, and TruffleRuby.
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
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
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 &amp; \*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 "&amp;"
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