markdown-merge 1.0.3 → 7.1.1

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +99 -482
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +5 -3
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +489 -47
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +77 -6
  16. data/lib/markdown/merge/debug_logger.rb +2 -2
  17. data/lib/markdown/merge/document_problems.rb +3 -3
  18. data/lib/markdown/merge/file_aligner.rb +433 -133
  19. data/lib/markdown/merge/file_analysis.rb +387 -51
  20. data/lib/markdown/merge/file_analysis_base.rb +188 -51
  21. data/lib/markdown/merge/gap_line_node.rb +14 -8
  22. data/lib/markdown/merge/link_definition_node.rb +5 -5
  23. data/lib/markdown/merge/link_parser.rb +60 -60
  24. data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
  25. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  26. data/lib/markdown/merge/list_merger.rb +322 -0
  27. data/lib/markdown/merge/markdown_structure.rb +3 -3
  28. data/lib/markdown/merge/merge_result.rb +321 -4
  29. data/lib/markdown/merge/node_type_normalizer.rb +6 -6
  30. data/lib/markdown/merge/output_builder.rb +101 -19
  31. data/lib/markdown/merge/partial_template_merger.rb +248 -27
  32. data/lib/markdown/merge/preservation_support.rb +291 -0
  33. data/lib/markdown/merge/smart_merger.rb +62 -14
  34. data/lib/markdown/merge/smart_merger_base.rb +929 -60
  35. data/lib/markdown/merge/table_match_algorithm.rb +22 -27
  36. data/lib/markdown/merge/table_match_refiner.rb +6 -10
  37. data/lib/markdown/merge/version.rb +5 -4
  38. data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
  39. data/lib/markdown/merge/wrapper_support.rb +194 -0
  40. data/lib/markdown/merge.rb +669 -122
  41. data/lib/markdown-merge.rb +9 -4
  42. data/sig/markdown/merge.rbs +3 -336
  43. data.tar.gz.sig +0 -0
  44. metadata +104 -93
  45. metadata.gz.sig +0 -0
  46. data/CHANGELOG.md +0 -308
  47. data/CITATION.cff +0 -20
  48. data/CODE_OF_CONDUCT.md +0 -134
  49. data/CONTRIBUTING.md +0 -227
  50. data/FUNDING.md +0 -74
  51. data/LICENSE.txt +0 -21
  52. data/REEK +0 -0
  53. data/RUBOCOP.md +0 -71
  54. data/SECURITY.md +0 -21
@@ -1,149 +1,696 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # External gems
4
- require "version_gem"
5
- require "set"
6
-
7
- # Shared merge infrastructure
8
- require "ast/merge"
9
-
10
- # tree_haver provides unified markdown parsing via multiple backends
11
- require "tree_haver"
12
-
13
- # This gem - only require version
14
- require_relative "merge/version"
3
+ require 'ast/merge'
4
+ require 'tree_haver'
5
+ require_relative 'merge/version'
15
6
 
16
7
  module Markdown
17
- # Smart merging for Markdown files using AST-based parsers via tree_haver.
18
- #
19
- # Markdown::Merge provides intelligent Markdown merging with support for
20
- # multiple parsing backends (Commonmarker, Markly) through tree_haver:
21
- # - Standalone SmartMerger that works with any available backend
22
- # - Matching structural elements (headings, paragraphs, lists, etc.) between files
23
- # - Preserving frozen sections marked with HTML comments
24
- # - Resolving conflicts based on configurable preferences
25
- # - Node type normalization for portable merge rules across backends
26
- #
27
- # Can be used directly or through parser-specific wrappers
28
- # (commonmarker-merge, markly-merge) that provide hard dependencies
29
- # and backend-specific defaults.
30
- #
31
- # @example Direct usage with auto backend detection
32
- # require "markdown/merge"
33
- # merger = Markdown::Merge::SmartMerger.new(template, destination)
34
- # result = merger.merge
35
- #
36
- # @example With specific backend
37
- # merger = Markdown::Merge::SmartMerger.new(
38
- # template,
39
- # destination,
40
- # backend: :markly,
41
- # flags: Markly::DEFAULT,
42
- # extensions: [:table, :strikethrough]
43
- # )
44
- # result = merger.merge
45
- #
46
- # @example Using via commonmarker-merge
47
- # require "commonmarker/merge"
48
- # merger = Commonmarker::Merge::SmartMerger.new(template, destination)
49
- # result = merger.merge
50
- #
51
- # @see SmartMerger Main entry point for merging
52
- # @see FileAnalysis For parsing and analyzing Markdown files
53
- # @see NodeTypeNormalizer For type normalization across backends
54
8
  module Merge
55
- # Base error class for Markdown::Merge
56
- # Inherits from Ast::Merge::Error for consistency across merge gems.
9
+ PACKAGE_NAME = 'markdown-merge'
10
+ BACKEND_REFERENCES = {
11
+ 'kreuzberg-language-pack' => TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND,
12
+ 'commonmarker' => TreeHaver::BackendReference.new(id: 'commonmarker', family: 'native').freeze,
13
+ 'markly' => TreeHaver::BackendReference.new(id: 'markly', family: 'native').freeze,
14
+ 'kramdown' => TreeHaver::BackendReference.new(id: 'kramdown', family: 'native').freeze
15
+ }.freeze
16
+ BACKEND_REGISTRY = Struct.new(:registered, :mutex).new(false, Mutex.new)
17
+
57
18
  class Error < Ast::Merge::Error; end
58
19
 
59
- # Raised when a Markdown file has parsing errors.
60
- # Inherits from Ast::Merge::ParseError for consistency across merge gems.
61
- #
62
- # @example Handling parse errors
63
- # begin
64
- # analysis = FileAnalysis.new(markdown_content)
65
- # rescue ParseError => e
66
- # puts "Markdown syntax error: #{e.message}"
67
- # e.errors.each { |error| puts " #{error}" }
68
- # end
69
20
  class ParseError < Ast::Merge::ParseError
70
- # @param message [String, nil] Error message (auto-generated if nil)
71
- # @param content [String, nil] The Markdown source that failed to parse
72
- # @param errors [Array] Parse errors from Markdown
73
21
  def initialize(message = nil, content: nil, errors: [])
74
22
  super(message, errors: errors, content: content)
75
23
  end
76
24
  end
77
25
 
78
- # Raised when the template file has syntax errors.
79
- #
80
- # @example Handling template parse errors
81
- # begin
82
- # merger = SmartMerger.new(template, destination)
83
- # result = merger.merge
84
- # rescue TemplateParseError => e
85
- # puts "Template syntax error: #{e.message}"
86
- # e.errors.each { |error| puts " #{error.message}" }
87
- # end
88
26
  class TemplateParseError < ParseError; end
89
27
 
90
- # Raised when the destination file has syntax errors.
91
- #
92
- # @example Handling destination parse errors
93
- # begin
94
- # merger = SmartMerger.new(template, destination)
95
- # result = merger.merge
96
- # rescue DestinationParseError => e
97
- # puts "Destination syntax error: #{e.message}"
98
- # e.errors.each { |error| puts " #{error.message}" }
99
- # end
100
28
  class DestinationParseError < ParseError; end
101
29
 
102
- # Autoload all components - base classes
103
- autoload :Cleanse, "markdown/merge/cleanse"
104
- autoload :DebugLogger, "markdown/merge/debug_logger"
105
- autoload :FreezeNode, "markdown/merge/freeze_node"
106
- autoload :FileAnalysisBase, "markdown/merge/file_analysis_base"
107
- autoload :FileAligner, "markdown/merge/file_aligner"
108
- autoload :ConflictResolver, "markdown/merge/conflict_resolver"
109
- autoload :MergeResult, "markdown/merge/merge_result"
110
- autoload :TableMatchAlgorithm, "markdown/merge/table_match_algorithm"
111
- autoload :TableMatchRefiner, "markdown/merge/table_match_refiner"
112
- autoload :CodeBlockMerger, "markdown/merge/code_block_merger"
113
- autoload :SmartMergerBase, "markdown/merge/smart_merger_base"
114
- autoload :LinkDefinitionNode, "markdown/merge/link_definition_node"
115
- autoload :GapLineNode, "markdown/merge/gap_line_node"
116
- autoload :OutputBuilder, "markdown/merge/output_builder"
117
- autoload :LinkDefinitionFormatter, "markdown/merge/link_definition_formatter"
118
- autoload :MarkdownStructure, "markdown/merge/markdown_structure"
119
- autoload :DocumentProblems, "markdown/merge/document_problems"
120
- autoload :WhitespaceNormalizer, "markdown/merge/whitespace_normalizer"
121
- autoload :LinkParser, "markdown/merge/link_parser"
122
- autoload :LinkReferenceRehydrator, "markdown/merge/link_reference_rehydrator"
123
-
124
- # Autoload concrete implementations (tree_haver-based)
125
- autoload :NodeTypeNormalizer, "markdown/merge/node_type_normalizer"
126
- autoload :FileAnalysis, "markdown/merge/file_analysis"
127
- autoload :SmartMerger, "markdown/merge/smart_merger"
128
- autoload :PartialTemplateMerger, "markdown/merge/partial_template_merger"
30
+ class CorruptionDetectedError < Error; end
31
+
32
+ autoload :BackendSupport, 'markdown/merge/backend_support'
33
+ autoload :Cleanse, 'markdown/merge/cleanse'
34
+ autoload :CodeBlockMatchRefiner, 'markdown/merge/code_block_match_refiner'
35
+ autoload :CodeBlockMerger, 'markdown/merge/code_block_merger'
36
+ autoload :CommentTracker, 'markdown/merge/comment_tracker'
37
+ autoload :ConflictResolver, 'markdown/merge/conflict_resolver'
38
+ autoload :DebugLogger, 'markdown/merge/debug_logger'
39
+ autoload :DocumentProblems, 'markdown/merge/document_problems'
40
+ autoload :FileAligner, 'markdown/merge/file_aligner'
41
+ autoload :FileAnalysis, 'markdown/merge/file_analysis'
42
+ autoload :FileAnalysisBase, 'markdown/merge/file_analysis_base'
43
+ autoload :FreezeNode, 'markdown/merge/freeze_node'
44
+ autoload :GapLineNode, 'markdown/merge/gap_line_node'
45
+ autoload :LinkDefinitionFormatter, 'markdown/merge/link_definition_formatter'
46
+ autoload :LinkDefinitionNode, 'markdown/merge/link_definition_node'
47
+ autoload :LinkParser, 'markdown/merge/link_parser'
48
+ autoload :LinkReferenceRehydrator, 'markdown/merge/link_reference_rehydrator'
49
+ autoload :ListMatchRefiner, 'markdown/merge/list_match_refiner'
50
+ autoload :ListMerger, 'markdown/merge/list_merger'
51
+ autoload :MarkdownStructure, 'markdown/merge/markdown_structure'
52
+ autoload :MergeResult, 'markdown/merge/merge_result'
53
+ autoload :NodeTypeNormalizer, 'markdown/merge/node_type_normalizer'
54
+ autoload :OutputBuilder, 'markdown/merge/output_builder'
55
+ autoload :PartialTemplateMerger, 'markdown/merge/partial_template_merger'
56
+ autoload :PreservationSupport, 'markdown/merge/preservation_support'
57
+ autoload :SmartMerger, 'markdown/merge/smart_merger'
58
+ autoload :SmartMergerBase, 'markdown/merge/smart_merger_base'
59
+ autoload :TableMatchAlgorithm, 'markdown/merge/table_match_algorithm'
60
+ autoload :TableMatchRefiner, 'markdown/merge/table_match_refiner'
61
+ autoload :WhitespaceNormalizer, 'markdown/merge/whitespace_normalizer'
62
+ autoload :WrapperSupport, 'markdown/merge/wrapper_support'
63
+
64
+ def register_backend!
65
+ BACKEND_REGISTRY.mutex.synchronize do
66
+ return if BACKEND_REGISTRY.registered
67
+
68
+ TreeHaver::BackendRegistry.register(BACKEND_REFERENCES.fetch('kreuzberg-language-pack'))
69
+
70
+ grammar_finder = TreeHaver::GrammarFinder.new(:markdown)
71
+ grammar_finder.register! if grammar_finder.available?
72
+
73
+ BACKEND_REGISTRY.registered = true
74
+ end
75
+ end
76
+
77
+ def markdown_feature_profile
78
+ {
79
+ family: 'markdown',
80
+ supported_dialects: ['markdown'],
81
+ supported_policies: []
82
+ }
83
+ end
84
+
85
+ def available_markdown_backends
86
+ BACKEND_REFERENCES.filter_map do |backend_id, reference|
87
+ reference if markdown_backend_available_for_analysis?(backend_id)
88
+ end
89
+ end
90
+
91
+ def markdown_backend_feature_profile(backend: nil)
92
+ resolved_backend = resolve_backend(backend)
93
+ unless BACKEND_REFERENCES.key?(resolved_backend)
94
+ return unsupported_feature_result("Unsupported Markdown backend #{resolved_backend}.")
95
+ end
96
+
97
+ markdown_feature_profile.merge(
98
+ backend: resolved_backend,
99
+ backend_ref: BACKEND_REFERENCES.fetch(resolved_backend).to_h
100
+ )
101
+ end
102
+
103
+ def markdown_plan_context(backend: nil)
104
+ profile = markdown_backend_feature_profile(backend: backend)
105
+ return profile if profile[:ok] == false
106
+
107
+ {
108
+ family_profile: markdown_feature_profile,
109
+ feature_profile: {
110
+ backend: profile[:backend],
111
+ supports_dialects: false,
112
+ supported_policies: profile[:supported_policies]
113
+ }
114
+ }
115
+ end
116
+
117
+ def parse_markdown(source, dialect, backend: nil)
118
+ return unsupported_feature_result("Unsupported Markdown dialect #{dialect}.") unless dialect == 'markdown'
119
+
120
+ resolved_backend = resolve_backend(backend)
121
+ unless BACKEND_REFERENCES.key?(resolved_backend)
122
+ return unsupported_feature_result("Unsupported Markdown backend #{resolved_backend}.")
123
+ end
124
+
125
+ register_backend!
126
+ parser = TreeHaver.with_backend(resolved_backend) { TreeHaver.parser_for(:markdown) }
127
+ tree = parser.parse(source)
128
+ collect_parse_errors(tree.root_node)
129
+
130
+ normalized_source = normalize_source(source)
131
+ {
132
+ ok: true,
133
+ diagnostics: [],
134
+ analysis: {
135
+ kind: 'markdown',
136
+ dialect: dialect,
137
+ normalized_source: normalized_source,
138
+ root_kind: 'document',
139
+ owners: collect_markdown_owners(normalized_source)
140
+ },
141
+ policies: []
142
+ }
143
+ rescue TreeHaver::Error, StandardError => e
144
+ parse_failure_result(e)
145
+ end
146
+
147
+ def match_markdown_owners(template, destination)
148
+ destination_paths = destination[:owners].to_h { |owner| [owner[:path], true] }
149
+ template_paths = template[:owners].to_h { |owner| [owner[:path], true] }
150
+
151
+ {
152
+ matched: template[:owners]
153
+ .filter { |owner| destination_paths[owner[:path]] }
154
+ .map { |owner| { template_path: owner[:path], destination_path: owner[:path] } },
155
+ unmatched_template: template[:owners].map { |owner| owner[:path] }.reject { |path| destination_paths[path] },
156
+ unmatched_destination: destination[:owners].map { |owner| owner[:path] }.reject { |path| template_paths[path] }
157
+ }
158
+ end
159
+
160
+ def merge_markdown(template_source, destination_source, dialect, backend: nil)
161
+ template = parse_markdown(template_source, dialect, backend: backend)
162
+ return template unless template[:ok]
163
+
164
+ destination = parse_markdown(destination_source, dialect, backend: backend)
165
+ return destination unless destination[:ok]
166
+
167
+ destination_sections = collect_markdown_sections(
168
+ destination.dig(:analysis, :normalized_source),
169
+ destination.dig(:analysis, :owners)
170
+ )
171
+ template_sections = collect_markdown_sections(
172
+ template.dig(:analysis, :normalized_source),
173
+ template.dig(:analysis, :owners)
174
+ )
175
+ destination_paths = destination_sections.to_h { |section| [section[:path], true] }
176
+ merged_sections = destination_sections.map { |section| section[:text] }.reject(&:empty?) +
177
+ template_sections
178
+ .reject { |section| destination_paths[section[:path]] || section[:text].empty? }
179
+ .map { |section| section[:text] }
180
+
181
+ {
182
+ ok: true,
183
+ diagnostics: [],
184
+ output: "#{merged_sections.join("\n\n").strip}\n",
185
+ policies: []
186
+ }
187
+ end
188
+
189
+ def markdown_embedded_families(analysis)
190
+ analysis[:owners].filter_map do |owner|
191
+ next unless owner[:owner_kind] == 'code_fence'
192
+ next if owner[:info_string].to_s.empty?
193
+
194
+ family = code_fence_family(owner[:info_string])
195
+ dialect = code_fence_dialect(owner[:info_string], family)
196
+ next unless family && dialect
197
+
198
+ {
199
+ path: owner[:path],
200
+ language: owner[:info_string],
201
+ family: family,
202
+ dialect: dialect
203
+ }
204
+ end
205
+ end
206
+
207
+ def markdown_discovered_surfaces(analysis)
208
+ markdown_embedded_families(analysis).map do |candidate|
209
+ Ast::Merge.discovered_surface(
210
+ surface_kind: 'markdown_fenced_code_block',
211
+ declared_language: candidate[:language],
212
+ effective_language: candidate[:dialect],
213
+ address: "document[0] > fenced_code_block[#{candidate[:path]}]",
214
+ parent_address: 'document[0]',
215
+ owner: Ast::Merge.surface_owner_ref(kind: 'structural_owner', address: candidate[:path]),
216
+ reconstruction_strategy: 'portable_write',
217
+ metadata: {
218
+ family: candidate[:family],
219
+ dialect: candidate[:dialect],
220
+ path: candidate[:path]
221
+ }
222
+ )
223
+ end
224
+ end
225
+
226
+ def markdown_delegated_child_operations(analysis, parent_operation_id: 'markdown-document-0')
227
+ markdown_discovered_surfaces(analysis).each_with_index.map do |surface, index|
228
+ Ast::Merge.delegated_child_operation(
229
+ operation_id: "markdown-fence-#{index}",
230
+ parent_operation_id: parent_operation_id,
231
+ requested_strategy: 'delegate_child_surface',
232
+ language_chain: ['markdown', surface[:effective_language]],
233
+ surface: surface
234
+ )
235
+ end
236
+ end
237
+
238
+ def apply_markdown_delegated_child_outputs(source, delegated_operations, apply_plan, applied_children)
239
+ lines = normalize_source(source).split("\n")
240
+ ranges = markdown_fence_ranges(source)
241
+ operations_by_id = delegated_operations.to_h { |operation| [operation[:operation_id], operation] }
242
+ outputs_by_id = applied_children.to_h { |entry| [entry[:operation_id], entry[:output]] }
243
+
244
+ replacements = apply_plan[:entries].filter_map do |entry|
245
+ operation = operations_by_id[entry.dig(:delegated_group, :child_operation_id)]
246
+ output = outputs_by_id[entry.dig(:delegated_group, :child_operation_id)]
247
+ next if operation.nil? || output.nil?
248
+
249
+ owner_path = operation.dig(:surface, :owner, :address)
250
+ range = ranges[owner_path]
251
+ if range.nil?
252
+ return {
253
+ ok: false,
254
+ diagnostics: [{ severity: 'error', category: 'configuration_error',
255
+ message: "missing fenced-code range for #{owner_path}" }],
256
+ policies: []
257
+ }
258
+ end
259
+
260
+ { range: range, output: output }
261
+ end
262
+
263
+ replacements.sort_by { |entry| -entry[:range][:start] }.each do |entry|
264
+ body_lines = entry[:output].empty? ? [] : entry[:output].sub(/\n\z/, '').split("\n")
265
+ lines[entry[:range][:start] + 1...entry[:range][:end]] = body_lines
266
+ end
267
+
268
+ {
269
+ ok: true,
270
+ diagnostics: [],
271
+ output: "#{lines.join("\n").sub(/\n+\z/, '')}\n",
272
+ policies: []
273
+ }
274
+ end
275
+
276
+ def merge_markdown_with_nested_outputs(template_source, destination_source, dialect, nested_outputs, backend: nil)
277
+ Ast::Merge.execute_nested_merge(
278
+ nested_outputs,
279
+ default_family: 'markdown',
280
+ request_id_prefix: 'nested_markdown_child',
281
+ merge_parent: -> { merge_markdown(template_source, destination_source, dialect, backend: backend) },
282
+ discover_operations: lambda { |merged_output|
283
+ analysis = parse_markdown(merged_output, dialect, backend: backend)
284
+ next { ok: false, diagnostics: analysis[:diagnostics] || [] } unless analysis[:ok]
285
+
286
+ {
287
+ ok: true,
288
+ diagnostics: [],
289
+ operations: markdown_delegated_child_operations(analysis[:analysis])
290
+ }
291
+ },
292
+ apply_resolved_outputs: lambda { |merged_output, operations, apply_plan, applied_children|
293
+ apply_markdown_delegated_child_outputs(
294
+ merged_output,
295
+ operations,
296
+ apply_plan,
297
+ applied_children
298
+ )
299
+ }
300
+ )
301
+ end
302
+
303
+ def merge_markdown_with_reviewed_nested_outputs(template_source, destination_source, dialect, review_state,
304
+ applied_children, backend: nil)
305
+ Ast::Merge.execute_reviewed_nested_merge(
306
+ review_state,
307
+ 'markdown',
308
+ applied_children,
309
+ merge_parent: -> { merge_markdown(template_source, destination_source, dialect, backend: backend) },
310
+ discover_operations: lambda { |merged_output|
311
+ analysis = parse_markdown(merged_output, dialect, backend: backend)
312
+ next({ ok: false, diagnostics: analysis[:diagnostics] || [] }) unless analysis[:ok]
313
+
314
+ {
315
+ ok: true,
316
+ diagnostics: [],
317
+ operations: markdown_delegated_child_operations(analysis[:analysis])
318
+ }
319
+ },
320
+ apply_resolved_outputs: lambda { |merged_output, operations, apply_plan, resolved_children|
321
+ apply_markdown_delegated_child_outputs(
322
+ merged_output,
323
+ operations,
324
+ apply_plan,
325
+ resolved_children
326
+ )
327
+ }
328
+ )
329
+ end
330
+
331
+ def merge_markdown_with_reviewed_nested_outputs_from_replay_bundle(template_source, destination_source, dialect,
332
+ replay_bundle, backend: nil)
333
+ execution = Array(replay_bundle[:reviewed_nested_executions]).find { |entry| entry[:family] == 'markdown' }
334
+ unless execution
335
+ return { ok: false,
336
+ diagnostics: [{ severity: 'error', category: 'configuration_error', message: 'review replay bundle does not include a reviewed nested execution for markdown.' }], policies: [] }
337
+ end
338
+
339
+ merge_markdown_with_reviewed_nested_outputs(
340
+ template_source,
341
+ destination_source,
342
+ dialect,
343
+ execution[:review_state],
344
+ execution[:applied_children],
345
+ backend: backend
346
+ )
347
+ end
348
+
349
+ def merge_markdown_with_reviewed_nested_outputs_from_review_state(template_source, destination_source, dialect,
350
+ review_state, backend: nil)
351
+ execution = Array(review_state[:reviewed_nested_executions]).find { |entry| entry[:family] == 'markdown' }
352
+ unless execution
353
+ return { ok: false,
354
+ diagnostics: [{ severity: 'error', category: 'configuration_error', message: 'review state does not include a reviewed nested execution for markdown.' }], policies: [] }
355
+ end
356
+
357
+ merge_markdown_with_reviewed_nested_outputs(
358
+ template_source,
359
+ destination_source,
360
+ dialect,
361
+ execution[:review_state],
362
+ execution[:applied_children],
363
+ backend: backend
364
+ )
365
+ end
366
+
367
+ def merge_markdown_with_reviewed_nested_outputs_from_replay_bundle_envelope(template_source, destination_source,
368
+ dialect, envelope, backend: nil)
369
+ replay_bundle, import_error = Ast::Merge.import_review_replay_bundle_envelope(envelope)
370
+ if import_error
371
+ return { ok: false,
372
+ diagnostics: [{ severity: 'error', category: import_error[:category], message: import_error[:message] }], policies: [] }
373
+ end
374
+
375
+ merge_markdown_with_reviewed_nested_outputs_from_replay_bundle(
376
+ template_source,
377
+ destination_source,
378
+ dialect,
379
+ replay_bundle,
380
+ backend: backend
381
+ )
382
+ end
383
+
384
+ def merge_markdown_with_reviewed_nested_outputs_from_review_state_envelope(template_source, destination_source,
385
+ dialect, envelope, backend: nil)
386
+ review_state, import_error = Ast::Merge.import_conformance_manifest_review_state_envelope(envelope)
387
+ if import_error
388
+ return { ok: false,
389
+ diagnostics: [{ severity: 'error', category: import_error[:category], message: import_error[:message] }], policies: [] }
390
+ end
391
+
392
+ merge_markdown_with_reviewed_nested_outputs_from_review_state(
393
+ template_source,
394
+ destination_source,
395
+ dialect,
396
+ review_state,
397
+ backend: backend
398
+ )
399
+ end
400
+
401
+ def normalize_source(source)
402
+ source.gsub(/\r\n?/, "\n")
403
+ end
404
+
405
+ def slugify(value)
406
+ slug = value
407
+ .strip
408
+ .downcase
409
+ .gsub(/[`*_~\[\]()<>]/, '')
410
+ .gsub(/[^a-z0-9]+/, '-')
411
+ .gsub(/\A-+|-+\z/, '')
412
+ slug.empty? ? 'section' : slug
413
+ end
414
+
415
+ def collect_markdown_owners(source)
416
+ owners = []
417
+ heading_index = 0
418
+ code_fence_index = 0
419
+ lines = source.split("\n")
420
+ index = 0
421
+
422
+ while index < lines.length
423
+ line = lines[index]
424
+ if (heading = line.match(/^(#+)\s+(.+?)\s*#*\s*$/)) && heading[1].length.between?(1, 6)
425
+ level = heading[1].length
426
+ owners << {
427
+ path: "/heading/#{heading_index}",
428
+ owner_kind: 'heading',
429
+ match_key: "h#{level}:#{slugify(heading[2])}",
430
+ level: level
431
+ }
432
+ heading_index += 1
433
+ index += 1
434
+ next
435
+ end
436
+
437
+ if (fence = line.match(/^\s*(`{3,}|~{3,})\s*(.*?)\s*$/))
438
+ marker = fence[1]
439
+ marker_char = marker[0]
440
+ marker_length = marker.length
441
+ info_string = fence[2].strip.split(/\s+/).first.to_s
442
+ owners << {
443
+ path: "/code_fence/#{code_fence_index}",
444
+ owner_kind: 'code_fence',
445
+ match_key: "fence:#{info_string.empty? ? 'plain' : info_string}",
446
+ **(info_string.empty? ? {} : { info_string: info_string })
447
+ }
448
+ code_fence_index += 1
449
+
450
+ index += 1
451
+ while index < lines.length
452
+ trimmed = lines[index].strip
453
+ break if trimmed.length >= marker_length &&
454
+ trimmed.start_with?(marker_char * marker_length) &&
455
+ trimmed.delete(marker_char).empty?
456
+
457
+ index += 1
458
+ end
459
+ index += 1
460
+ next
461
+ end
462
+
463
+ index += 1
464
+ end
465
+
466
+ owners
467
+ end
468
+
469
+ def markdown_owner_start_indices(source)
470
+ starts = {}
471
+ lines = normalize_source(source).split("\n")
472
+ heading_index = 0
473
+ code_fence_index = 0
474
+ index = 0
475
+
476
+ while index < lines.length
477
+ line = lines[index]
478
+ if (heading = line.match(/^(#+)\s+(.+?)\s*#*\s*$/)) && heading[1].length.between?(1, 6)
479
+ starts["/heading/#{heading_index}"] = index
480
+ heading_index += 1
481
+ index += 1
482
+ next
483
+ end
484
+
485
+ if (fence = line.match(/^\s*(`{3,}|~{3,})\s*(.*?)\s*$/))
486
+ starts["/code_fence/#{code_fence_index}"] = index
487
+ code_fence_index += 1
488
+ marker = fence[1]
489
+ marker_char = marker[0]
490
+ marker_length = marker.length
491
+ index += 1
492
+ while index < lines.length
493
+ trimmed = lines[index].strip
494
+ break if trimmed.length >= marker_length &&
495
+ trimmed.start_with?(marker_char * marker_length) &&
496
+ trimmed.delete(marker_char).empty?
497
+
498
+ index += 1
499
+ end
500
+ index += 1
501
+ next
502
+ end
503
+
504
+ index += 1
505
+ end
506
+
507
+ starts
508
+ end
509
+
510
+ def collect_markdown_sections(source, owners)
511
+ lines = normalize_source(source).split("\n")
512
+ starts = markdown_owner_start_indices(source)
513
+ ordered = owners.filter_map do |owner|
514
+ start = starts[owner[:path]]
515
+ next if start.nil?
516
+
517
+ { owner: owner, start: start }
518
+ end.sort_by { |entry| entry[:start] }
519
+
520
+ ordered.each_with_index.map do |entry, index|
521
+ finish = ordered[index + 1]&.dig(:start) || lines.length
522
+ {
523
+ path: entry.dig(:owner, :path),
524
+ text: lines[entry[:start]...finish].join("\n").strip
525
+ }
526
+ end
527
+ end
528
+
529
+ def markdown_fence_ranges(source)
530
+ ranges = {}
531
+ code_fence_index = 0
532
+ lines = normalize_source(source).split("\n")
533
+ index = 0
534
+
535
+ while index < lines.length
536
+ line = lines[index]
537
+ if (fence = line.match(/^\s*(`{3,}|~{3,})\s*(.*?)\s*$/))
538
+ marker = fence[1]
539
+ marker_char = marker[0]
540
+ marker_length = marker.length
541
+ closing_index = index
542
+ cursor = index + 1
543
+ while cursor < lines.length
544
+ trimmed = lines[cursor].strip
545
+ if trimmed.length >= marker_length &&
546
+ trimmed.start_with?(marker_char * marker_length) &&
547
+ trimmed.delete(marker_char).empty?
548
+ closing_index = cursor
549
+ break
550
+ end
551
+ closing_index = cursor if cursor == lines.length - 1
552
+ cursor += 1
553
+ end
554
+
555
+ ranges["/code_fence/#{code_fence_index}"] = { start: index, end: closing_index }
556
+ code_fence_index += 1
557
+ index = closing_index + 1
558
+ next
559
+ end
560
+
561
+ index += 1
562
+ end
563
+
564
+ ranges
565
+ end
566
+
567
+ def code_fence_family(info_string)
568
+ case info_string.to_s.downcase
569
+ when 'ts', 'typescript'
570
+ 'typescript'
571
+ when 'rust', 'rs'
572
+ 'rust'
573
+ when 'go'
574
+ 'go'
575
+ when 'json', 'jsonc', 'json5'
576
+ 'json'
577
+ when 'yaml', 'yml'
578
+ 'yaml'
579
+ when 'toml'
580
+ 'toml'
581
+ end
582
+ end
583
+
584
+ def code_fence_dialect(info_string, family)
585
+ case family
586
+ when 'typescript', 'rust', 'go', 'yaml', 'toml'
587
+ family
588
+ when 'json'
589
+ %w[json jsonc json5].include?(info_string.to_s.downcase) ? info_string.to_s.downcase : 'json'
590
+ end
591
+ end
592
+
593
+ def resolve_backend(backend)
594
+ return backend.to_s unless backend.to_s.empty?
595
+
596
+ current = TreeHaver.current_backend_id
597
+ return current if BACKEND_REFERENCES.key?(current.to_s) && markdown_backend_available_for_analysis?(current)
598
+
599
+ BACKEND_REFERENCES.keys.find { |backend_id| markdown_backend_available_for_analysis?(backend_id) } ||
600
+ 'kreuzberg-language-pack'
601
+ end
602
+
603
+ def markdown_backend_available_for_analysis?(backend_id)
604
+ register_backend!
605
+ registrations = TreeHaver.registered_languages(:markdown)
606
+ case backend_id.to_s
607
+ when 'commonmarker', 'markly', 'kramdown'
608
+ registrations.dig(backend_id.to_sym, :backend_module)&.then do |backend_module|
609
+ !backend_module.respond_to?(:available?) || backend_module.available?
610
+ end
611
+ when 'kreuzberg-language-pack'
612
+ registrations.key?(:tree_sitter) || registrations.key?(:tslp)
613
+ else
614
+ false
615
+ end
616
+ end
617
+
618
+ def collect_parse_errors(node)
619
+ raise TreeHaver::NotAvailable, 'Markdown parse returned no root node' unless node
620
+ return unless node.respond_to?(:has_error?) && node.has_error?
621
+
622
+ raise TreeHaver::NotAvailable,
623
+ 'Markdown parse contains syntax errors'
624
+ end
625
+
626
+ def parse_failure_result(error)
627
+ {
628
+ ok: false,
629
+ diagnostics: [{ severity: 'error', category: 'parse_error', message: error.message }],
630
+ policies: []
631
+ }
632
+ end
633
+
634
+ def unsupported_feature_result(message)
635
+ {
636
+ ok: false,
637
+ diagnostics: [{ severity: 'error', category: 'unsupported_feature', message: message }],
638
+ policies: []
639
+ }
640
+ end
641
+
642
+ module_function(
643
+ :register_backend!,
644
+ :markdown_feature_profile,
645
+ :available_markdown_backends,
646
+ :markdown_backend_feature_profile,
647
+ :markdown_plan_context,
648
+ :parse_markdown,
649
+ :match_markdown_owners,
650
+ :merge_markdown,
651
+ :markdown_embedded_families,
652
+ :markdown_discovered_surfaces,
653
+ :markdown_delegated_child_operations,
654
+ :apply_markdown_delegated_child_outputs,
655
+ :merge_markdown_with_reviewed_nested_outputs,
656
+ :merge_markdown_with_reviewed_nested_outputs_from_replay_bundle,
657
+ :merge_markdown_with_reviewed_nested_outputs_from_replay_bundle_envelope,
658
+ :merge_markdown_with_reviewed_nested_outputs_from_review_state,
659
+ :merge_markdown_with_reviewed_nested_outputs_from_review_state_envelope,
660
+ :merge_markdown_with_nested_outputs,
661
+ :normalize_source,
662
+ :slugify,
663
+ :collect_markdown_owners,
664
+ :markdown_owner_start_indices,
665
+ :collect_markdown_sections,
666
+ :markdown_fence_ranges,
667
+ :code_fence_family,
668
+ :code_fence_dialect,
669
+ :resolve_backend,
670
+ :markdown_backend_available_for_analysis?,
671
+ :collect_parse_errors,
672
+ :parse_failure_result,
673
+ :unsupported_feature_result
674
+ )
129
675
  end
130
676
  end
131
677
 
132
- # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
133
- # Note: markdown-merge requires a backend (markly or commonmarker) to instantiate,
134
- # so we use skip_instantiation: true
135
- # Only register if MergeGemRegistry is loaded (i.e., in test environment)
678
+ %w[
679
+ commonmarker/merge/backend
680
+ markly/merge/backend
681
+ ].each do |feature|
682
+ require feature
683
+ rescue LoadError
684
+ nil
685
+ end
686
+
136
687
  if defined?(Ast::Merge::RSpec::MergeGemRegistry)
137
688
  Ast::Merge::RSpec::MergeGemRegistry.register(
138
689
  :markdown_merge,
139
- require_path: "markdown/merge",
140
- merger_class: "Markdown::Merge::SmartMerger",
690
+ require_path: 'markdown/merge',
691
+ merger_class: 'Markdown::Merge::SmartMerger',
141
692
  test_source: "# Test\n\nParagraph",
142
693
  category: :markdown,
143
- skip_instantiation: true,
694
+ skip_instantiation: true
144
695
  )
145
696
  end
146
-
147
- Markdown::Merge::Version.class_eval do
148
- extend VersionGem::Basic
149
- end