ruby-merge 7.0.0 → 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.
data/lib/ruby/merge.rb CHANGED
@@ -1,42 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tree_haver"
4
- require "ast/merge"
3
+ require 'digest'
4
+ require 'tree_haver'
5
+ require 'ast/merge'
6
+ require_relative 'merge/version'
7
+ require_relative 'merge/block_directive_detector'
8
+ require_relative 'merge/block_binding_support'
9
+ require_relative 'merge/doc_comment_support'
10
+ require_relative 'merge/gemspec_support'
11
+ require_relative 'merge/magic_comment_support'
12
+ require_relative 'merge/method_similarity'
13
+ require_relative 'merge/nocov_node_base'
14
+ require_relative 'merge/nocov_wrapper_base'
15
+ require_relative 'merge/rescue_semantics'
16
+ require_relative 'merge/scaffold_chunk_support'
17
+ require_relative 'merge/signature_support'
5
18
 
6
19
  module Ruby
7
20
  module Merge
8
21
  extend self
22
+ include Ast::Merge::SourceRegionReportSupport
9
23
 
10
- PACKAGE_NAME = "ruby-merge"
24
+ PACKAGE_NAME = 'ruby-merge'
11
25
  TREE_SITTER_BACKEND = TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND
12
- DESTINATION_WINS_ARRAY_POLICY = { surface: "array", name: "destination_wins_array" }.freeze
13
- DIRECTIVE_LINE = /\A(?::nocov:|[\w-]+:(?:freeze|unfreeze))\z/
14
- MAGIC_COMMENT_PREFIXES = %w[coding encoding frozen_string_literal shareable_constant_value typed warn_indent].freeze
15
- REQUIRE_PATTERN = /^\s*require(?:_relative)?\s+["']([^"']+)["']/.freeze
16
- DSL_CALL_PATTERN = /^(?<name>source|gemspec|git_source|gem|eval_gemfile|platform|group|desc|task)\b/.freeze
17
- RAKEFILE_DEFAULT_TASK_COMMENT = "# Define a base default task early so other files can enhance it."
18
- RAKEFILE_DEFAULT_TASK_DESC = 'desc "Default tasks aggregator"'
19
- CLASS_PATTERN = /^\s*class\s+([A-Z]\w*(?:::\w+)*)/.freeze
20
- MODULE_PATTERN = /^\s*module\s+([A-Z]\w*(?:::\w+)*)/.freeze
21
- DEF_PATTERN = /^\s*def\s+(?:self\.)?([a-zA-Z_]\w*[!?=]?)/.freeze
22
- EXAMPLE_TAG = /\A@example\b(?<rest>.*)\z/.freeze
23
- TAG_PREFIX = /\A@[a-z_]+\b/.freeze
26
+ DESTINATION_WINS_ARRAY_POLICY = { surface: 'array', name: 'destination_wins_array' }.freeze
27
+ DEFAULT_METHOD_MOVE_POLICY = 'destination_order'
28
+ BACKEND_REGISTRY = Struct.new(:registered, :mutex).new(false, Mutex.new)
29
+ TslpSpan = Struct.new(:start_row, :start_col, :end_row, :end_col, keyword_init: true)
30
+ TslpStructureItem = Struct.new(:kind, :name, :span, keyword_init: true)
31
+ TslpImportItem = Struct.new(:source, :span, keyword_init: true)
32
+ TslpProcessAnalysis = Struct.new(:structure, :imports, keyword_init: true)
33
+ PERCENT_ARRAY_DELIMITER_PAIRS = {
34
+ '[' => ']',
35
+ '(' => ')',
36
+ '{' => '}',
37
+ '<' => '>'
38
+ }.freeze
39
+ REQUIRE_PATTERN = /^\s*require(?:_relative)?\s+["']([^"']+)["']/
40
+ CLASS_PATTERN = /^\s*class\s+([A-Z]\w*(?:::\w+)*)/
41
+ MODULE_PATTERN = /^\s*module\s+([A-Z]\w*(?:::\w+)*)/
42
+ DEF_PATTERN = %r{^\s*def\s+((?:self\.)?)([a-zA-Z_]\w*[!?=]?|\[\]=?|\+@|-@|\*\*|<<|>>|<=>|===|==|=~|!~|!=|[+\-*/%&|^<>]=?|[!~`])}
43
+ CONSTANT_ASSIGNMENT_PATTERN = /^(\s*)([A-Z]\w*)\s*=/
44
+ CONSTANT_HASH_ASSIGNMENT_PATTERN = /^(\s*)([A-Z]\w*)\s*=\s*\{/
45
+
46
+ def register_backend!
47
+ BACKEND_REGISTRY.mutex.synchronize do
48
+ return if BACKEND_REGISTRY.registered
49
+
50
+ TreeHaver::BackendRegistry.register(TREE_SITTER_BACKEND)
51
+
52
+ grammar_finder = TreeHaver::GrammarFinder.new(:ruby)
53
+ grammar_finder.register! if grammar_finder.available?
54
+
55
+ BACKEND_REGISTRY.registered = true
56
+ end
57
+ end
24
58
 
25
59
  def ruby_feature_profile
26
60
  {
27
- family: "ruby",
28
- supported_dialects: ["ruby"],
61
+ family: 'ruby',
62
+ supported_dialects: ['ruby'],
29
63
  supported_policies: [DESTINATION_WINS_ARRAY_POLICY]
30
64
  }
31
65
  end
32
66
 
33
67
  def available_ruby_backends
34
- [TREE_SITTER_BACKEND]
68
+ ruby_backend_available_for_analysis?(TREE_SITTER_BACKEND.id) ? [TREE_SITTER_BACKEND] : []
69
+ end
70
+
71
+ def ruby_tslp_capability_profile
72
+ {
73
+ import_records: TreeHaver::BackendRegistry.tag_available?(:tslp_ruby_import_records),
74
+ top_level_call_records: TreeHaver::BackendRegistry.tag_available?(:tslp_ruby_top_level_call_records)
75
+ }
35
76
  end
36
77
 
37
78
  def ruby_backend_feature_profile(backend: nil)
38
- requested = backend.to_s.empty? ? TREE_SITTER_BACKEND.id : backend.to_s
39
- return unsupported_feature_result("Unsupported Ruby backend #{requested}.") unless requested == TREE_SITTER_BACKEND.id
79
+ requested = requested_tree_sitter_backend_id(backend)
80
+ unless ruby_backend_available_for_analysis?(requested)
81
+ return unsupported_feature_result("Unsupported Ruby backend #{requested}.")
82
+ end
40
83
 
41
84
  ruby_feature_profile.merge(
42
85
  backend: requested,
@@ -59,77 +102,214 @@ module Ruby
59
102
  }
60
103
  end
61
104
 
105
+ def ruby_backend_available_for_analysis?(backend_id)
106
+ register_backend!
107
+
108
+ if backend_id.to_s.empty?
109
+ TreeHaver.parser_for(:ruby, backend_type: :tree_sitter)
110
+ else
111
+ TreeHaver.with_backend(backend_id) { TreeHaver.parser_for(:ruby, backend_type: :tree_sitter) }
112
+ end
113
+ true
114
+ rescue TreeHaver::Error, ArgumentError
115
+ false
116
+ end
117
+
62
118
  def parse_ruby(source, dialect, backend: nil)
63
- requested = backend.to_s.empty? ? TREE_SITTER_BACKEND.id : backend.to_s
64
- return unsupported_feature_result("Unsupported Ruby dialect #{dialect}.") unless dialect == "ruby"
65
- return unsupported_feature_result("Unsupported Ruby backend #{requested}.") unless requested == TREE_SITTER_BACKEND.id
119
+ requested = backend.to_s.empty? ? nil : backend.to_s
120
+ return unsupported_feature_result("Unsupported Ruby dialect #{dialect}.") unless dialect == 'ruby'
66
121
 
67
- syntax = TreeHaver.parse_with_language_pack(
68
- TreeHaver::ParserRequest.new(source: source, language: "ruby", dialect: dialect)
69
- )
70
- return { ok: false, diagnostics: syntax[:diagnostics], policies: [] } unless syntax[:ok]
122
+ unless ruby_backend_available_for_analysis?(requested)
123
+ diagnostic_backend = requested || TreeHaver.current_backend_id || 'tree-sitter'
124
+ return unsupported_feature_result("Unsupported Ruby backend #{diagnostic_backend}.")
125
+ end
126
+
127
+ tree = parse_tree_sitter_source(:ruby, source, backend: requested)
128
+ collect_parse_errors(tree.root_node)
71
129
 
130
+ process_analysis = ruby_process_analysis_from_tree(source, tree.root_node)
72
131
  {
73
132
  ok: true,
74
133
  diagnostics: [],
75
- analysis: analyze_ruby_document(source),
134
+ analysis: analyze_ruby_document(source, process_analysis: process_analysis),
76
135
  policies: []
77
136
  }
137
+ rescue TreeHaver::Error, StandardError => e
138
+ parse_failure_result(e)
78
139
  end
79
140
 
141
+ def parse_tree_sitter_source(language, source, backend: nil)
142
+ if backend
143
+ TreeHaver.with_backend(backend) { TreeHaver.parser_for(language, backend_type: :tree_sitter).parse(source) }
144
+ else
145
+ TreeHaver.parser_for(language, backend_type: :tree_sitter).parse(source)
146
+ end
147
+ end
148
+ private_class_method :parse_tree_sitter_source
149
+
150
+ def requested_tree_sitter_backend_id(backend)
151
+ return backend.to_s unless backend.to_s.empty?
152
+
153
+ contextual = TreeHaver.current_backend_id || ENV['TREE_HAVER_BACKEND']
154
+ contextual.to_s.empty? || contextual.to_s == 'auto' ? TREE_SITTER_BACKEND.id : contextual.to_s
155
+ end
156
+ private_class_method :requested_tree_sitter_backend_id
157
+
80
158
  def match_ruby_owners(template, destination)
81
- destination_paths = destination[:owners].to_h { |owner| [owner[:path], true] }
82
- template_paths = template[:owners].to_h { |owner| [owner[:path], true] }
83
- {
84
- matched: template[:owners]
85
- .filter { |owner| destination_paths[owner[:path]] }
86
- .map { |owner| { template_path: owner[:path], destination_path: owner[:path] } },
87
- unmatched_template: template[:owners].map { |owner| owner[:path] }.reject { |path| destination_paths[path] },
88
- unmatched_destination: destination[:owners].map { |owner| owner[:path] }.reject { |path| template_paths[path] }
89
- }
159
+ Ast::Merge::OwnerSelection.match_by_path(template, destination)
90
160
  end
91
161
 
92
- def merge_ruby(template_source, destination_source, dialect, merge_template_requires: false)
162
+ def ruby_method_move_detection(template_source, destination_source, dialect)
163
+ return unsupported_feature_result("Unsupported Ruby dialect #{dialect}.") unless dialect == 'ruby'
164
+
165
+ template_methods = ruby_method_projection(template_source, revision: 'template')
166
+ destination_methods = ruby_method_projection(destination_source, revision: 'destination')
167
+ destination_by_signature = destination_methods.to_h { |entry| [entry[:signature], entry] }
168
+ template_signatures = template_methods.map { |entry| entry[:signature] }.to_h { |signature| [signature, true] }
169
+
170
+ matches = template_methods.filter_map do |template_entry|
171
+ destination_entry = destination_by_signature[template_entry[:signature]]
172
+ next unless destination_entry
173
+
174
+ moved = template_entry[:index] != destination_entry[:index] || template_entry[:parent_path] != destination_entry[:parent_path]
175
+ Ast::Merge::MoveDetectionMatch.new(
176
+ from_path: template_entry[:path],
177
+ to_path: destination_entry[:path],
178
+ from_node_id: template_entry[:node_id],
179
+ to_node_id: destination_entry[:node_id],
180
+ signature: template_entry[:signature],
181
+ moved: moved,
182
+ from_parent_path: template_entry[:parent_path],
183
+ to_parent_path: destination_entry[:parent_path],
184
+ from_index: template_entry[:index],
185
+ to_index: destination_entry[:index],
186
+ confidence: moved ? 0.98 : 0.9,
187
+ diagnostics: [moved ? 'same Ruby method signature observed at a different sibling position' : 'same Ruby method signature observed at the same sibling position']
188
+ )
189
+ end
190
+
191
+ matched_template_signatures = matches.map(&:signature).to_h { |signature| [signature, true] }
192
+ Ast::Merge::MoveDetectionMatchingReport.new(
193
+ matching_id: 'ruby-method-move-detection',
194
+ strategy: 'move_detection',
195
+ from_revision: 'template',
196
+ to_revision: 'destination',
197
+ capability: Ast::Merge::MoveDetectionCapability.new(
198
+ name: 'move_detection',
199
+ enabled: true,
200
+ default_enabled: false,
201
+ requires_stable_node_identity: true
202
+ ),
203
+ matches: matches,
204
+ unmatched_from: template_methods.reject do |entry|
205
+ matched_template_signatures[entry[:signature]]
206
+ end.map { |entry| entry[:path] },
207
+ unmatched_to: destination_methods.reject do |entry|
208
+ template_signatures[entry[:signature]]
209
+ end.map { |entry| entry[:path] },
210
+ diagnostics: ['Ruby method move detection uses generic move-detection matching over receiver-aware method projections']
211
+ ).to_h
212
+ end
213
+
214
+ def merge_ruby(template_source, destination_source, dialect, merge_template_requires: false,
215
+ method_move_policy: DEFAULT_METHOD_MOVE_POLICY)
93
216
  template = parse_ruby(template_source, dialect)
94
217
  return template unless template[:ok]
95
218
 
219
+ method_move_policy = normalize_method_move_policy(method_move_policy)
220
+
96
221
  destination = parse_ruby(destination_source, dialect)
97
222
  unless destination[:ok]
98
223
  return {
99
224
  ok: false,
100
225
  diagnostics: destination[:diagnostics].map do |diagnostic|
101
- diagnostic[:category] == "parse_error" ? diagnostic.merge(category: "destination_parse_error") : diagnostic
226
+ diagnostic[:category] == 'parse_error' ? diagnostic.merge(category: 'destination_parse_error') : diagnostic
102
227
  end,
103
228
  policies: []
104
229
  }
105
230
  end
106
231
 
107
- destination_requires = collect_ruby_require_entries(destination.dig(:analysis, :source))
108
- template_requires = collect_ruby_require_entries(template.dig(:analysis, :source))
109
- destination_declarations = collect_ruby_declaration_entries(destination.dig(:analysis, :source))
110
- template_declarations = collect_ruby_declaration_entries(template.dig(:analysis, :source))
111
- destination_paths = destination_declarations.to_h { |entry| [entry[:path], true] }
112
- destination_dsl = collect_top_level_dsl_entries(destination.dig(:analysis, :source))
113
- template_dsl = collect_top_level_dsl_entries(template.dig(:analysis, :source))
232
+ destination_context = ruby_tslp_merge_context(destination.fetch(:analysis), role: 'destination')
233
+ return destination_context unless destination_context[:ok]
234
+
235
+ template_context = ruby_tslp_merge_context(template.fetch(:analysis), role: 'template')
236
+ return template_context unless template_context[:ok]
237
+
238
+ destination_requires = destination_context.fetch(:requires)
239
+ template_requires = template_context.fetch(:requires)
240
+ destination_declarations = destination_context.fetch(:declarations)
241
+ template_declarations = template_context.fetch(:declarations)
242
+ template_declarations_by_key = template_declarations.to_h { |entry| [entry[:merge_key], entry] }
243
+ intra_owner_merges = ruby_intra_owner_merge_plan(template_declarations, destination_declarations)
244
+ namespace_conflicts = ruby_namespace_form_conflicts(template_declarations, destination_declarations)
245
+ unless namespace_conflicts.empty? || TreeHaver::BackendRegistry.tag_available?(:tslp_ruby_namespace_form_equivalence)
246
+ conflicts = namespace_conflicts.join(', ')
247
+ return unsupported_feature_result(
248
+ 'ruby-merge cannot reconcile equivalent Ruby namespace declaration forms with the active TSLP records: ' \
249
+ "#{conflicts}. Use prism-merge for native Ruby merging, or report missing Ruby namespace ownership " \
250
+ 'records to tree-sitter-language-pack.'
251
+ )
252
+ end
253
+ if !namespace_conflicts.empty? && TreeHaver::BackendRegistry.tag_available?(:tslp_ruby_namespace_form_equivalence)
254
+ template_declarations += qualified_nested_declaration_entries(template_declarations)
255
+ template_declarations_by_key = template_declarations.to_h { |entry| [entry[:merge_key], entry] }
256
+ end
257
+ destination_paths = destination_declarations.to_h { |entry| [entry[:merge_key], true] }
114
258
  sections = []
115
- preamble = collect_ruby_preamble(destination.dig(:analysis, :source))
116
- sections << preamble unless preamble.empty?
117
- requires = merge_template_requires ? merge_ruby_requires(destination_requires, template_requires) : destination_requires
259
+ preamble = destination_context.fetch(:preamble)
260
+ sections << { text: preamble } unless preamble.empty?
261
+ requires = if merge_template_requires
262
+ merge_ruby_requires(destination_requires,
263
+ template_requires)
264
+ else
265
+ destination_requires
266
+ end
118
267
  require_block = requires.map { |entry| entry[:text] }.join("\n").strip
119
- sections << require_block unless require_block.empty?
120
- sections.concat(merge_top_level_dsl_entries(destination_dsl, template_dsl).map { |entry| entry[:text] })
121
- sections.concat(destination_declarations.map { |entry| entry[:text] })
268
+ sections << ruby_top_level_section(require_block, requires) unless require_block.empty?
269
+ sections.concat(
270
+ destination_declarations.map do |entry|
271
+ ruby_top_level_section(
272
+ merge_ruby_declaration_entry(template_declarations_by_key[entry[:merge_key]], entry)[:text],
273
+ [entry]
274
+ )
275
+ end
276
+ )
122
277
  sections.concat(
123
- template_declarations.reject { |entry| destination_paths[entry[:path]] }.map { |entry| entry[:text] }
278
+ template_declarations.reject do |entry|
279
+ destination_paths[entry[:merge_key]] ||
280
+ namespace_wrapper_matched?(entry, template_declarations, destination_paths)
281
+ end.map { |entry| { text: entry[:text] } }
124
282
  )
283
+ destination_footer = destination_context.fetch(:footer)
284
+ sections << { text: destination_footer } unless destination_footer.empty?
125
285
 
126
- output = "#{sections.join("\n\n").strip}\n"
286
+ output = emit_ruby_top_level_sections(destination_source, sections)
287
+ matching_reports = [ruby_method_move_detection(template_source, destination_source, dialect)]
288
+ moved_method_count = matching_reports.sum do |report|
289
+ Array(report[:matches]).count { |entry| entry[:moved] }
290
+ end
127
291
 
128
292
  {
129
293
  ok: true,
130
294
  diagnostics: [],
131
- output: normalize_rakefile_default_task_scaffold(output),
132
- policies: [DESTINATION_WINS_ARRAY_POLICY]
295
+ output: output,
296
+ policies: [DESTINATION_WINS_ARRAY_POLICY],
297
+ matching_reports: matching_reports,
298
+ merge_planning: {
299
+ method_move_policy: method_move_policy,
300
+ method_move_detection: {
301
+ matching_id: 'ruby-tslp-method-move-detection',
302
+ moved_method_count: moved_method_count,
303
+ preserves_destination_order: method_move_policy == DEFAULT_METHOD_MOVE_POLICY,
304
+ suppresses_duplicate_moved_methods: method_move_policy == DEFAULT_METHOD_MOVE_POLICY,
305
+ override_scope: 'per_file_recipe'
306
+ },
307
+ intra_owner_merges: {
308
+ strategy: 'destination_wins_scoped_owner_body',
309
+ merge_count: intra_owner_merges.length,
310
+ merges: intra_owner_merges
311
+ }
312
+ }
133
313
  }
134
314
  end
135
315
 
@@ -137,34 +317,34 @@ module Ruby
137
317
  analysis[:discovered_surfaces] || []
138
318
  end
139
319
 
140
- def ruby_delegated_child_operations(analysis, parent_operation_id: "ruby-document-0")
320
+ def ruby_delegated_child_operations(analysis, parent_operation_id: 'ruby-document-0')
141
321
  surfaces = ruby_discovered_surfaces(analysis)
142
322
  doc_operation_ids = {}
143
323
  operations = []
144
324
 
145
325
  surfaces.each_with_index do |surface, index|
146
- next unless surface[:surface_kind] == "ruby_doc_comment"
326
+ next unless surface[:surface_kind] == 'ruby_doc_comment'
147
327
 
148
328
  operation_id = "ruby-doc-comment-#{index}"
149
329
  doc_operation_ids[surface[:address]] = operation_id
150
330
  operations << Ast::Merge.delegated_child_operation(
151
331
  operation_id: operation_id,
152
332
  parent_operation_id: parent_operation_id,
153
- requested_strategy: "delegate_child_surface",
154
- language_chain: ["ruby", surface[:effective_language]],
333
+ requested_strategy: 'delegate_child_surface',
334
+ language_chain: ['ruby', surface[:effective_language]],
155
335
  surface: surface
156
336
  )
157
337
  end
158
338
 
159
339
  example_index = 0
160
340
  surfaces.each do |surface|
161
- next unless surface[:surface_kind] == "yard_example_block"
341
+ next unless surface[:surface_kind] == 'yard_example_block'
162
342
 
163
343
  operations << Ast::Merge.delegated_child_operation(
164
344
  operation_id: "yard-example-#{example_index}",
165
345
  parent_operation_id: doc_operation_ids.fetch(surface[:parent_address], parent_operation_id),
166
- requested_strategy: "delegate_child_surface",
167
- language_chain: ["ruby", "yard", surface[:effective_language]],
346
+ requested_strategy: 'delegate_child_surface',
347
+ language_chain: ['ruby', 'yard', surface[:effective_language]],
168
348
  surface: surface
169
349
  )
170
350
  example_index += 1
@@ -173,6 +353,721 @@ module Ruby
173
353
  operations
174
354
  end
175
355
 
356
+ def ruby_source_regions(source)
357
+ lines = normalize_source(source).lines(chomp: true)
358
+ owners = top_level_source_region_owners(lines)
359
+
360
+ {
361
+ regions: source_interleaved_regions_for_report(lines: lines, owners: owners),
362
+ trailing_newline: normalize_source(source).end_with?("\n")
363
+ }
364
+ end
365
+
366
+ def ruby_source_owner_identity_profile(source)
367
+ identities = collect_ruby_declaration_entries(source).flat_map do |entry|
368
+ declaration_identity = source_owner_identity_entry(
369
+ kind: entry[:kind],
370
+ name: entry[:name],
371
+ parent_scope: '/',
372
+ address: entry[:path],
373
+ content: entry[:text]
374
+ )
375
+ method_identities = direct_body_method_entries(entry[:text]).map do |method_entry|
376
+ source_owner_identity_entry(
377
+ kind: 'method',
378
+ name: method_entry[:signature],
379
+ parent_scope: entry[:path],
380
+ address: "#{entry[:path]}/methods/#{method_entry[:signature]}",
381
+ content: method_entry[:body_text]
382
+ )
383
+ end
384
+ [declaration_identity, *method_identities]
385
+ end
386
+ add_source_owner_occurrence_indexes(identities)
387
+ end
388
+
389
+ def ruby_source_owner_identity_matches(template_source, destination_source)
390
+ template_identities = ruby_source_owner_identity_profile(template_source)
391
+ destination_identities = ruby_source_owner_identity_profile(destination_source)
392
+ destination_groups = destination_identities.group_by { |identity| identity[:structural_identity] }
393
+ template_identities.group_by { |identity| identity[:structural_identity] }
394
+ matched_destination_addresses = {}
395
+
396
+ matches = template_identities.filter_map do |template_identity|
397
+ destination_identity = destination_groups.fetch(template_identity[:structural_identity], []).find do |candidate|
398
+ candidate[:occurrence_index] == template_identity[:occurrence_index]
399
+ end
400
+ next unless destination_identity
401
+
402
+ matched_destination_addresses[destination_identity[:address]] = true
403
+ {
404
+ template_address: template_identity[:address],
405
+ destination_address: destination_identity[:address],
406
+ structural_identity: template_identity[:structural_identity],
407
+ occurrence_index: template_identity[:occurrence_index],
408
+ confidence: 'structural_ordered'
409
+ }
410
+ end
411
+
412
+ matched_template_addresses = matches.to_h { |match| [match[:template_address], true] }
413
+ {
414
+ confidence_profile: ruby_source_owner_match_confidence_profile,
415
+ matches: matches,
416
+ unmatched_template: template_identities.reject do |identity|
417
+ matched_template_addresses[identity[:address]]
418
+ end.map { |identity| identity[:address] },
419
+ unmatched_destination: destination_identities.reject do |identity|
420
+ matched_destination_addresses[identity[:address]]
421
+ end.map { |identity| identity[:address] },
422
+ diagnostics: [
423
+ {
424
+ severity: 'info',
425
+ category: 'source_owner_identity_matching',
426
+ message: 'Ruby source-owner matching reports confidence per match and uses ordered structural pairing for duplicate identities.'
427
+ }
428
+ ]
429
+ }
430
+ end
431
+
432
+ def ruby_ambiguous_source_owner_identity_report(source)
433
+ identities = ruby_source_owner_identity_profile(source)
434
+ ambiguities = identities
435
+ .group_by { |identity| identity[:structural_identity] }
436
+ .filter_map do |structural_identity, entries|
437
+ next if entries.length < 2
438
+
439
+ {
440
+ structural_identity: structural_identity,
441
+ occurrence_count: entries.length,
442
+ addresses: entries.map { |entry| entry[:address] },
443
+ ambiguity_kind: 'duplicate_structural_identity',
444
+ resolution_model: 'ordered_cursor',
445
+ confidence: 'structural_ordered'
446
+ }
447
+ end
448
+
449
+ {
450
+ ambiguities: ambiguities,
451
+ diagnostics: if ambiguities.empty?
452
+ []
453
+ else
454
+ [
455
+ {
456
+ severity: 'warning',
457
+ category: 'ambiguous_source_owner_identity',
458
+ message: 'Repeated Ruby source-owner identities require ordered cursor matching.'
459
+ }
460
+ ]
461
+ end
462
+ }
463
+ end
464
+
465
+ def ruby_source_owner_match_confidence_profile
466
+ {
467
+ levels: [
468
+ {
469
+ name: 'exact',
470
+ meaning: 'same structural identity, occurrence index, and content identity'
471
+ },
472
+ {
473
+ name: 'structural_ordered',
474
+ meaning: 'same structural identity and occurrence index'
475
+ },
476
+ {
477
+ name: 'content_hash',
478
+ meaning: 'same content-derived identity when structural identity is ambiguous'
479
+ },
480
+ {
481
+ name: 'token_similar',
482
+ meaning: 'similar token content below exact content identity'
483
+ },
484
+ {
485
+ name: 'unresolved',
486
+ meaning: 'identity is ambiguous and must not be auto-matched'
487
+ }
488
+ ]
489
+ }
490
+ end
491
+
492
+ def ruby_fallback_policy_profile
493
+ {
494
+ policy_id: 'ruby-source-fallback-policy',
495
+ baseline_provider: {
496
+ provider_id: 'host_baseline_merge',
497
+ integration_point: true
498
+ },
499
+ scopes: %w[node subtree owned_region whole_file],
500
+ triggers: [
501
+ { reason: 'binary_input', scope: 'whole_file' },
502
+ { reason: 'unsupported_structural_merge_capability', scope: 'whole_file' },
503
+ { reason: 'no_structural_owners', scope: 'whole_file' },
504
+ { reason: 'both_branches_create_file', scope: 'whole_file' },
505
+ { reason: 'excessive_duplicate_identities', scope: 'owned_region' },
506
+ { reason: 'timeout_or_resource_budget', scope: 'whole_file' },
507
+ { reason: 'backend_diagnostic_threshold', scope: 'owned_region' }
508
+ ],
509
+ reporting_fields: %w[activated reason scope selected_baseline structured_result_discarded]
510
+ }
511
+ end
512
+
513
+ def ruby_fallback_activation_report(reason:, scope:, selected_baseline: 'host_baseline_merge',
514
+ structured_result_discarded: true)
515
+ {
516
+ activated: true,
517
+ reason: reason,
518
+ scope: scope,
519
+ selected_baseline: selected_baseline,
520
+ structured_result_discarded: structured_result_discarded,
521
+ policy_id: ruby_fallback_policy_profile.fetch(:policy_id),
522
+ diagnostics: [
523
+ {
524
+ severity: 'warning',
525
+ category: 'fallback_applied',
526
+ message: "Ruby source fallback activated for #{reason} at #{scope} scope."
527
+ }
528
+ ]
529
+ }
530
+ end
531
+
532
+ def ruby_never_worse_fallback_mode
533
+ {
534
+ mode_id: 'never_worse_than_baseline',
535
+ enabled: true,
536
+ baseline_provider: ruby_fallback_policy_profile.dig(:baseline_provider, :provider_id),
537
+ comparison: {
538
+ conflict_count: 'structured_must_not_exceed_baseline',
539
+ conflict_scope: 'structured_must_not_be_broader_than_baseline',
540
+ data_loss: 'structured_must_not_drop_clean_branch_content'
541
+ },
542
+ fallback_action: 'discard_structured_result_and_use_baseline',
543
+ diagnostics: [
544
+ {
545
+ severity: 'info',
546
+ category: 'never_worse_fallback_mode',
547
+ message: 'Ruby fallback comparison mode treats the host baseline merge as the safety floor.'
548
+ }
549
+ ]
550
+ }
551
+ end
552
+
553
+ def ruby_post_merge_validation_profile
554
+ {
555
+ profile_id: 'ruby-post-merge-validation',
556
+ phase: 'post_merge_validation',
557
+ separate_from: %w[merge_planning rendering],
558
+ checks: %w[
559
+ reparse_merged_output
560
+ resolved_owners_present
561
+ owner_count_not_unexpectedly_lower
562
+ unchanged_significant_lines_preserved
563
+ branch_added_significant_lines_preserved
564
+ output_length_within_policy_bounds
565
+ conflict_marker_shape_compatible
566
+ ],
567
+ failure_outcomes: %w[fallback_to_baseline scoped_conflict hard_diagnostic_failure],
568
+ hooks: {
569
+ ci: 'strict',
570
+ exploratory: 'permissive_when_explicit'
571
+ }
572
+ }
573
+ end
574
+
575
+ def ruby_conflict_diagnostics_profile
576
+ {
577
+ profile_id: 'ruby-source-conflict-diagnostics',
578
+ conflict_kinds: %w[
579
+ both_modified
580
+ both_added
581
+ modify_delete
582
+ rename_rename
583
+ rename_modify
584
+ order_sensitive_sibling_additions
585
+ interstitial_conflict
586
+ validation_failure
587
+ ],
588
+ risk_levels: %w[text_only syntax_level semantic_risk unknown],
589
+ marker_compatibility: {
590
+ standard_markers: true,
591
+ enhanced_metadata: 'sidecar_or_review_state'
592
+ },
593
+ audit_fields: %w[
594
+ owner_identity
595
+ owner_kind
596
+ strategy_chosen
597
+ match_confidence
598
+ fallback_reason
599
+ validation_warnings
600
+ conflict_kind
601
+ conflict_scope
602
+ ],
603
+ stable_for_review_replay: true
604
+ }
605
+ end
606
+
607
+ def ruby_formatter_policy_profile
608
+ {
609
+ profile_id: 'ruby-source-formatter-policy',
610
+ adapter_phase: 'optional_post_merge_adapter',
611
+ semantic_validation: 'not_proven_by_formatter',
612
+ policies: %w[
613
+ no_formatter
614
+ validate_only
615
+ format_after_clean_merge
616
+ format_after_fallback
617
+ formatter_failure_is_warning
618
+ formatter_failure_is_hard_error
619
+ ],
620
+ portable_fixture_default: 'no_formatter',
621
+ formatter_execution_in_portable_expectations: 'only_when_fixture_opts_in',
622
+ invariants: %w[owner_identity conflict_scope validation_semantics]
623
+ }
624
+ end
625
+
626
+ def ruby_formatter_adapter_report(pre_format_output:, formatted_output:, policy: 'validate_only',
627
+ conflict_scope: 'none')
628
+ pre_format_owners = ruby_source_owner_identity_profile(pre_format_output)
629
+ formatted_owners = ruby_source_owner_identity_profile(formatted_output)
630
+ formatted_owner_signatures = stable_owner_signatures(formatted_owners)
631
+ owners_preserved = stable_owner_signatures(pre_format_owners) == formatted_owner_signatures
632
+ whitespace_repaired = pre_format_output != formatted_output
633
+
634
+ {
635
+ policy: policy,
636
+ formatter_profile: ruby_formatter_policy_profile.fetch(:profile_id),
637
+ adapter_phase: 'optional_post_merge_adapter',
638
+ semantic_validation: 'not_proven_by_formatter',
639
+ whitespace_repaired: whitespace_repaired,
640
+ owners_preserved: owners_preserved,
641
+ conflict_scope_preserved: true,
642
+ validation_semantics_preserved: true,
643
+ conflict_scope: conflict_scope,
644
+ portable_expectation: 'formatter_not_executed_unless_fixture_opts_in',
645
+ owner_signatures: formatted_owner_signatures,
646
+ diagnostics: [
647
+ {
648
+ severity: owners_preserved ? 'info' : 'error',
649
+ category: owners_preserved ? 'formatter_adapter_accepted' : 'formatter_adapter_rejected',
650
+ message: owners_preserved ? 'Ruby formatter adapter preserved owner identity, conflict scope, and validation semantics.' : 'Ruby formatter adapter changed owner identity and cannot be accepted as a semantic merge.'
651
+ }
652
+ ]
653
+ }
654
+ end
655
+
656
+ def ruby_ast_node_merge_strategy_profile
657
+ {
658
+ profile_id: 'ruby-optional-ast-node-merge',
659
+ merge_surfaces: %w[owner ast_node line hybrid],
660
+ optional_fine_grained_profiles: %w[expression argument_list hash_literal_pair],
661
+ child_ordering_strategies: %w[destination_order successor_constraints pcs_like_triples],
662
+ public_contract_level: 'ruleset_and_fixture',
663
+ default_surface: 'owner',
664
+ backend_strategy_choices: %w[entity_level ast_level line_level hybrid],
665
+ reconstruction_policy: {
666
+ preserve_original_text_unless_backend_declares_renderer: true,
667
+ conflict_marker_placement_requires_text_boundary: true,
668
+ risky_reconstruction_outcome: 'fallback_or_scoped_conflict'
669
+ }
670
+ }
671
+ end
672
+
673
+ def ruby_ast_node_merge_candidate_report(surface:, base:, template:, destination:, reconstruction_risk: false)
674
+ {
675
+ surface: surface,
676
+ strategy_profile: ruby_ast_node_merge_strategy_profile.fetch(:profile_id),
677
+ candidate_strategy: reconstruction_risk ? 'fallback_or_scoped_conflict' : 'hybrid_ast_node_merge',
678
+ owner_level_fallback_too_blunt: !reconstruction_risk,
679
+ successor_ordering_available: true,
680
+ pcs_like_strategy_available: true,
681
+ public_contract_level: 'ruleset_and_fixture',
682
+ backend_strategy_choices: ruby_ast_node_merge_strategy_profile.fetch(:backend_strategy_choices),
683
+ inputs: {
684
+ base: base,
685
+ template: template,
686
+ destination: destination
687
+ },
688
+ reconstruction: {
689
+ risky: reconstruction_risk,
690
+ outcome: reconstruction_risk ? 'fallback_or_scoped_conflict' : 'preserve_original_text_boundaries'
691
+ },
692
+ diagnostics: [
693
+ {
694
+ severity: reconstruction_risk ? 'warning' : 'info',
695
+ category: reconstruction_risk ? 'ast_node_reconstruction_risk' : 'ast_node_merge_candidate',
696
+ message: reconstruction_risk ? 'Ruby AST-node merge candidate has ambiguous whitespace, comment, or marker reconstruction boundaries.' : 'Ruby AST-node merge candidate can be considered when owner-level fallback would be too blunt.'
697
+ }
698
+ ]
699
+ }
700
+ end
701
+
702
+ def ruby_vcs_tool_integration_profile
703
+ {
704
+ profile_id: 'ruby-vcs-tool-integration',
705
+ hosts: {
706
+ git_merge_driver: {
707
+ contract: 'git_merge_driver',
708
+ placeholders: %w[%O %A %B %P],
709
+ output_target: '%A',
710
+ standard_marker_modes: %w[diff3 zdiff3 merge],
711
+ marker_size: 'host_provided'
712
+ },
713
+ jujutsu_merge_tool: {
714
+ contract: 'jj_merge_tool',
715
+ roles: %w[base left right output path],
716
+ output_target: 'output',
717
+ standard_marker_modes: %w[diff3 merge],
718
+ marker_size: 'host_provided'
719
+ }
720
+ },
721
+ enhanced_markers: {
722
+ optional: true,
723
+ requires_host_tolerance: true,
724
+ default: 'standard_markers'
725
+ },
726
+ audit_artifact: {
727
+ enabled: true,
728
+ formats: %w[json],
729
+ fields: %w[host operation path fallback_reason validation_warnings conflict_kind timeout_ms]
730
+ },
731
+ resource_budget: {
732
+ timeout_ms: 5000,
733
+ timeout_outcome: 'fallback_or_driver_error',
734
+ cannot_hang_vcs_operation: true
735
+ },
736
+ diagnostics: %w[
737
+ structured_merge_skipped
738
+ fallback_activated
739
+ driver_invocation_error
740
+ tool_invocation_error
741
+ timeout_or_resource_budget
742
+ ]
743
+ }
744
+ end
745
+
746
+ def ruby_vcs_tool_invocation_report(host:, event:, path:, timeout_ms: 5000)
747
+ severity = event.to_s.end_with?('error') ? 'error' : 'warning'
748
+
749
+ {
750
+ host: host,
751
+ event: event,
752
+ path: path,
753
+ integration_profile: ruby_vcs_tool_integration_profile.fetch(:profile_id),
754
+ marker_mode: 'standard_markers',
755
+ marker_size: 'host_provided',
756
+ audit_artifact: {
757
+ format: 'json',
758
+ required: true
759
+ },
760
+ timeout_ms: timeout_ms,
761
+ resource_budget_enforced: true,
762
+ diagnostics: [
763
+ {
764
+ severity: severity,
765
+ category: event,
766
+ message: "Ruby #{host} integration reported #{event} for #{path}."
767
+ }
768
+ ]
769
+ }
770
+ end
771
+
772
+ def ruby_silent_data_loss_validation_report(template_source:, destination_source:, output:)
773
+ significant_inputs = {
774
+ template: significant_source_lines(template_source),
775
+ destination: significant_source_lines(destination_source)
776
+ }
777
+ output_lines = significant_source_lines(output).to_h { |line| [line, true] }
778
+ missing = significant_inputs.flat_map do |side, lines|
779
+ lines.reject { |line| output_lines[line] }.map do |line|
780
+ {
781
+ side: side.to_s,
782
+ line: line,
783
+ check: 'branch_added_significant_lines_preserved'
784
+ }
785
+ end
786
+ end
787
+
788
+ {
789
+ ok: missing.empty?,
790
+ validation_profile: ruby_post_merge_validation_profile.fetch(:profile_id),
791
+ failures: missing,
792
+ outcome: missing.empty? ? 'accepted' : 'hard_diagnostic_failure',
793
+ diagnostics: if missing.empty?
794
+ []
795
+ else
796
+ [
797
+ {
798
+ severity: 'error',
799
+ category: 'silent_data_loss_prevention',
800
+ message: 'Ruby post-merge validation detected significant input lines missing from output.'
801
+ }
802
+ ]
803
+ end
804
+ }
805
+ end
806
+
807
+ def ruby_fallback_scope_guard_report(requested_scope:, declared_scope:)
808
+ widened = ruby_fallback_scope_rank(requested_scope) > ruby_fallback_scope_rank(declared_scope)
809
+ {
810
+ requested_scope: requested_scope,
811
+ declared_scope: declared_scope,
812
+ widened: widened,
813
+ activated: !widened,
814
+ diagnostics: [
815
+ {
816
+ severity: widened ? 'error' : 'info',
817
+ category: widened ? 'fallback_scope_widening_rejected' : 'fallback_scope_accepted',
818
+ message: widened ? "Ruby fallback cannot widen from #{declared_scope} to #{requested_scope} without an explicit policy." : 'Ruby fallback scope is within the declared policy.'
819
+ }
820
+ ]
821
+ }
822
+ end
823
+
824
+ def ruby_interstitial_merge_policy_profile
825
+ {
826
+ policy_id: 'ruby-source-interstitial-merge',
827
+ separates_owner_merge: true,
828
+ region_kinds: %w[file_header file_footer container_header container_footer between],
829
+ owner_adjacency_fields: %w[previous_owner next_owner],
830
+ rules: [
831
+ {
832
+ region_kind: 'require',
833
+ ordering: 'destination_order_then_template_additions',
834
+ duplicate_key: 'require_path'
835
+ },
836
+ {
837
+ region_kind: 'blank_line',
838
+ ownership: 'preserve_declared_region_owner'
839
+ },
840
+ {
841
+ region_kind: 'comment',
842
+ attachment: 'nearest_declared_owner_or_standalone'
843
+ }
844
+ ]
845
+ }
846
+ end
847
+
848
+ def ruby_child_group_profile
849
+ {
850
+ profile_id: 'ruby-source-child-groups',
851
+ groups: [
852
+ {
853
+ owner_kind: 'class',
854
+ child_group: 'methods',
855
+ ordering: 'policy_ordered',
856
+ ordering_policy: DEFAULT_METHOD_MOVE_POLICY,
857
+ commutative: false,
858
+ visibility_sections: %w[public protected private]
859
+ },
860
+ {
861
+ owner_kind: 'class',
862
+ child_group: 'constants',
863
+ ordering: 'destination_order_then_template_additions',
864
+ commutative: false
865
+ },
866
+ {
867
+ owner_kind: 'module',
868
+ child_group: 'declarations',
869
+ ordering: 'destination_order_then_template_additions',
870
+ commutative: false
871
+ }
872
+ ],
873
+ diagnostics: [
874
+ {
875
+ severity: 'info',
876
+ category: 'ruby_child_group_profile',
877
+ message: 'Ruby child groups preserve destination order unless an explicit policy says otherwise.'
878
+ }
879
+ ]
880
+ }
881
+ end
882
+
883
+ def ruby_interstitial_comment_attachment_report(source)
884
+ lines = normalize_source(source).lines(chomp: true)
885
+ owners = top_level_source_region_owners(lines)
886
+ source_comment_block_attachment_report(
887
+ lines: lines,
888
+ owners: owners,
889
+ comment_line: method(:comment_line?)
890
+ )
891
+ end
892
+
893
+ def ruby_blank_line_ownership_report(source)
894
+ regions = ruby_source_regions(source)[:regions]
895
+ {
896
+ blank_line_regions: source_blank_line_ownership_regions(regions: regions)
897
+ }
898
+ end
899
+
900
+ def ruby_rename_detection_policy_profile
901
+ {
902
+ policy_id: 'ruby-source-rename-detection',
903
+ capability: {
904
+ name: 'rename_detection',
905
+ enabled: true,
906
+ default_enabled: false,
907
+ explicit: true
908
+ },
909
+ signals: %w[body_hash_with_owner_name_normalization structural_hash token_similarity parent_scope_similarity
910
+ backend_native_move_metadata],
911
+ clean_rename_confidence: 'content_hash',
912
+ conflict_policy: 'report_rename_plus_edit'
913
+ }
914
+ end
915
+
916
+ def ruby_rename_detection(template_source, destination_source)
917
+ template_methods = ruby_method_identity_entries(template_source)
918
+ destination_methods = ruby_method_identity_entries(destination_source)
919
+ destination_by_parent_and_body = destination_methods.group_by do |entry|
920
+ [entry[:parent_scope], entry[:normalized_body_identity]]
921
+ end
922
+ destination_signature_keys = destination_methods.to_h do |entry|
923
+ [[entry[:parent_scope], entry[:signature]], true]
924
+ end
925
+ matched_destination_addresses = {}
926
+
927
+ renames = template_methods.filter_map do |template_entry|
928
+ next if destination_signature_keys[[template_entry[:parent_scope], template_entry[:signature]]]
929
+
930
+ destination_entry = destination_by_parent_and_body.fetch(
931
+ [template_entry[:parent_scope], template_entry[:normalized_body_identity]],
932
+ []
933
+ ).find { |entry| entry[:signature] != template_entry[:signature] }
934
+ next unless destination_entry
935
+
936
+ matched_destination_addresses[destination_entry[:address]] = true
937
+ {
938
+ from_address: template_entry[:address],
939
+ to_address: destination_entry[:address],
940
+ from_name: template_entry[:signature],
941
+ to_name: destination_entry[:signature],
942
+ parent_scope: template_entry[:parent_scope],
943
+ confidence: 'content_hash',
944
+ signals: %w[body_hash_with_owner_name_normalization parent_scope_similarity],
945
+ clean_rename: true
946
+ }
947
+ end
948
+
949
+ {
950
+ policy: ruby_rename_detection_policy_profile,
951
+ renames: renames,
952
+ diagnostics: if renames.empty?
953
+ []
954
+ else
955
+ [
956
+ {
957
+ severity: 'info',
958
+ category: 'ruby_rename_detection',
959
+ message: 'Ruby rename detection is explicit and reports clean same-parent method renames by normalized body hash.'
960
+ }
961
+ ]
962
+ end,
963
+ unmatched_destination: destination_methods.reject do |entry|
964
+ matched_destination_addresses[entry[:address]]
965
+ end.map { |entry| entry[:address] }
966
+ }
967
+ end
968
+
969
+ def ruby_rename_plus_edit_conflicts(base_source, template_source, destination_source)
970
+ base_methods = ruby_method_identity_entries(base_source)
971
+ template_methods = ruby_method_identity_entries(template_source)
972
+ destination_methods = ruby_method_identity_entries(destination_source)
973
+ template_by_parent = template_methods.group_by { |entry| entry[:parent_scope] }
974
+ destination_by_parent = destination_methods.group_by { |entry| entry[:parent_scope] }
975
+
976
+ conflicts = base_methods.filter_map do |base_entry|
977
+ template_candidates = template_by_parent.fetch(base_entry[:parent_scope], []).reject do |entry|
978
+ entry[:signature] == base_entry[:signature]
979
+ end
980
+ destination_candidates = destination_by_parent.fetch(base_entry[:parent_scope], []).reject do |entry|
981
+ entry[:signature] == base_entry[:signature]
982
+ end
983
+ next if template_candidates.empty? || destination_candidates.empty?
984
+
985
+ template_candidate = template_candidates.first
986
+ destination_candidate = destination_candidates.first
987
+ next if template_candidate[:signature] == destination_candidate[:signature]
988
+
989
+ {
990
+ base_address: base_entry[:address],
991
+ template_address: template_candidate[:address],
992
+ destination_address: destination_candidate[:address],
993
+ parent_scope: base_entry[:parent_scope],
994
+ conflict_kind: 'rename_plus_edit',
995
+ fallback_scope: 'owned_region',
996
+ confidence: 'unresolved',
997
+ diagnostics: [
998
+ 'both branches renamed the same Ruby owner differently',
999
+ 'method body identity changed on at least one side'
1000
+ ]
1001
+ }
1002
+ end
1003
+
1004
+ {
1005
+ policy: ruby_rename_detection_policy_profile,
1006
+ conflicts: conflicts,
1007
+ diagnostics: if conflicts.empty?
1008
+ []
1009
+ else
1010
+ [
1011
+ {
1012
+ severity: 'warning',
1013
+ category: 'ruby_rename_plus_edit_conflict',
1014
+ message: 'Ruby rename detection found incompatible rename-plus-edit changes.'
1015
+ }
1016
+ ]
1017
+ end
1018
+ }
1019
+ end
1020
+
1021
+ def ruby_cross_container_method_move_detection(template_source, destination_source)
1022
+ template_methods = ruby_method_identity_entries(template_source)
1023
+ destination_methods = ruby_method_identity_entries(destination_source)
1024
+ destination_by_signature_and_body = destination_methods.group_by do |entry|
1025
+ [entry[:signature], entry[:normalized_body_identity]]
1026
+ end
1027
+
1028
+ moves = template_methods.filter_map do |template_entry|
1029
+ destination_entry = destination_by_signature_and_body.fetch(
1030
+ [template_entry[:signature], template_entry[:normalized_body_identity]],
1031
+ []
1032
+ ).find { |entry| entry[:parent_scope] != template_entry[:parent_scope] }
1033
+ next unless destination_entry
1034
+
1035
+ {
1036
+ from_address: template_entry[:address],
1037
+ to_address: destination_entry[:address],
1038
+ from_parent_scope: template_entry[:parent_scope],
1039
+ to_parent_scope: destination_entry[:parent_scope],
1040
+ signature: template_entry[:signature],
1041
+ moved: true,
1042
+ move_kind: 'cross_container',
1043
+ ordering_policy: DEFAULT_METHOD_MOVE_POLICY,
1044
+ preserves_destination_order: true,
1045
+ confidence: 'content_hash'
1046
+ }
1047
+ end
1048
+
1049
+ {
1050
+ capability: {
1051
+ name: 'move_detection',
1052
+ enabled: true,
1053
+ default_enabled: false,
1054
+ requires_stable_node_identity: true
1055
+ },
1056
+ moves: moves,
1057
+ diagnostics: if moves.empty?
1058
+ []
1059
+ else
1060
+ [
1061
+ {
1062
+ severity: 'info',
1063
+ category: 'ruby_cross_container_method_move',
1064
+ message: 'Ruby detected same-signature method movement across containers while preserving destination order.'
1065
+ }
1066
+ ]
1067
+ end
1068
+ }
1069
+ end
1070
+
176
1071
  def apply_ruby_delegated_child_outputs(source, delegated_operations, apply_plan, applied_children)
177
1072
  lines = normalize_source(source).split("\n")
178
1073
  operations_by_id = delegated_operations.to_h { |operation| [operation[:operation_id], operation] }
@@ -189,14 +1084,20 @@ module Ruby
189
1084
 
190
1085
  replacements.sort_by { |entry| -entry[:start] }.each do |entry|
191
1086
  prefix = comment_prefix_for(lines[entry[:start]])
192
- replacement_lines = entry[:output].empty? ? [] : entry[:output].sub(/\n\z/, "").split("\n").map { |line| "#{prefix}#{line}" }
1087
+ replacement_lines = if entry[:output].empty?
1088
+ []
1089
+ else
1090
+ entry[:output].sub(/\n\z/, '').split("\n").map do |line|
1091
+ "#{prefix}#{line}"
1092
+ end
1093
+ end
193
1094
  lines[entry[:start]..entry[:finish]] = replacement_lines
194
1095
  end
195
1096
 
196
1097
  {
197
1098
  ok: true,
198
1099
  diagnostics: [],
199
- output: "#{lines.join("\n").sub(/\n+\z/, "")}\n",
1100
+ output: "#{lines.join("\n").sub(/\n+\z/, '')}\n",
200
1101
  policies: [DESTINATION_WINS_ARRAY_POLICY]
201
1102
  }
202
1103
  end
@@ -204,8 +1105,8 @@ module Ruby
204
1105
  def merge_ruby_with_nested_outputs(template_source, destination_source, dialect, nested_outputs)
205
1106
  Ast::Merge.execute_nested_merge(
206
1107
  nested_outputs,
207
- default_family: "ruby",
208
- request_id_prefix: "nested_ruby_child",
1108
+ default_family: 'ruby',
1109
+ request_id_prefix: 'nested_ruby_child',
209
1110
  merge_parent: -> { merge_ruby(template_source, destination_source, dialect) },
210
1111
  discover_operations: lambda { |merged_output|
211
1112
  analysis = parse_ruby(merged_output, dialect)
@@ -228,10 +1129,11 @@ module Ruby
228
1129
  )
229
1130
  end
230
1131
 
231
- def merge_ruby_with_reviewed_nested_outputs(template_source, destination_source, dialect, review_state, applied_children)
1132
+ def merge_ruby_with_reviewed_nested_outputs(template_source, destination_source, dialect, review_state,
1133
+ applied_children)
232
1134
  Ast::Merge.execute_reviewed_nested_merge(
233
1135
  review_state,
234
- "ruby",
1136
+ 'ruby',
235
1137
  applied_children,
236
1138
  merge_parent: -> { merge_ruby(template_source, destination_source, dialect) },
237
1139
  discover_operations: lambda { |merged_output|
@@ -255,9 +1157,13 @@ module Ruby
255
1157
  )
256
1158
  end
257
1159
 
258
- def merge_ruby_with_reviewed_nested_outputs_from_replay_bundle(template_source, destination_source, dialect, replay_bundle)
259
- execution = Array(replay_bundle[:reviewed_nested_executions]).find { |entry| entry[:family] == "ruby" }
260
- return { ok: false, diagnostics: [{ severity: "error", category: "configuration_error", message: "review replay bundle does not include a reviewed nested execution for ruby." }], policies: [] } unless execution
1160
+ def merge_ruby_with_reviewed_nested_outputs_from_replay_bundle(template_source, destination_source, dialect,
1161
+ replay_bundle)
1162
+ execution = Array(replay_bundle[:reviewed_nested_executions]).find { |entry| entry[:family] == 'ruby' }
1163
+ unless execution
1164
+ return { ok: false,
1165
+ diagnostics: [{ severity: 'error', category: 'configuration_error', message: 'review replay bundle does not include a reviewed nested execution for ruby.' }], policies: [] }
1166
+ end
261
1167
 
262
1168
  merge_ruby_with_reviewed_nested_outputs(
263
1169
  template_source,
@@ -268,9 +1174,13 @@ module Ruby
268
1174
  )
269
1175
  end
270
1176
 
271
- def merge_ruby_with_reviewed_nested_outputs_from_review_state(template_source, destination_source, dialect, review_state)
272
- execution = Array(review_state[:reviewed_nested_executions]).find { |entry| entry[:family] == "ruby" }
273
- return { ok: false, diagnostics: [{ severity: "error", category: "configuration_error", message: "review state does not include a reviewed nested execution for ruby." }], policies: [] } unless execution
1177
+ def merge_ruby_with_reviewed_nested_outputs_from_review_state(template_source, destination_source, dialect,
1178
+ review_state)
1179
+ execution = Array(review_state[:reviewed_nested_executions]).find { |entry| entry[:family] == 'ruby' }
1180
+ unless execution
1181
+ return { ok: false,
1182
+ diagnostics: [{ severity: 'error', category: 'configuration_error', message: 'review state does not include a reviewed nested execution for ruby.' }], policies: [] }
1183
+ end
274
1184
 
275
1185
  merge_ruby_with_reviewed_nested_outputs(
276
1186
  template_source,
@@ -281,9 +1191,13 @@ module Ruby
281
1191
  )
282
1192
  end
283
1193
 
284
- def merge_ruby_with_reviewed_nested_outputs_from_replay_bundle_envelope(template_source, destination_source, dialect, envelope)
1194
+ def merge_ruby_with_reviewed_nested_outputs_from_replay_bundle_envelope(template_source, destination_source,
1195
+ dialect, envelope)
285
1196
  replay_bundle, import_error = Ast::Merge.import_review_replay_bundle_envelope(envelope)
286
- return { ok: false, diagnostics: [{ severity: "error", category: import_error[:category], message: import_error[:message] }], policies: [] } if import_error
1197
+ if import_error
1198
+ return { ok: false,
1199
+ diagnostics: [{ severity: 'error', category: import_error[:category], message: import_error[:message] }], policies: [] }
1200
+ end
287
1201
 
288
1202
  merge_ruby_with_reviewed_nested_outputs_from_replay_bundle(
289
1203
  template_source,
@@ -293,9 +1207,13 @@ module Ruby
293
1207
  )
294
1208
  end
295
1209
 
296
- def merge_ruby_with_reviewed_nested_outputs_from_review_state_envelope(template_source, destination_source, dialect, envelope)
1210
+ def merge_ruby_with_reviewed_nested_outputs_from_review_state_envelope(template_source, destination_source,
1211
+ dialect, envelope)
297
1212
  review_state, import_error = Ast::Merge.import_conformance_manifest_review_state_envelope(envelope)
298
- return { ok: false, diagnostics: [{ severity: "error", category: import_error[:category], message: import_error[:message] }], policies: [] } if import_error
1213
+ if import_error
1214
+ return { ok: false,
1215
+ diagnostics: [{ severity: 'error', category: import_error[:category], message: import_error[:message] }], policies: [] }
1216
+ end
299
1217
 
300
1218
  merge_ruby_with_reviewed_nested_outputs_from_review_state(
301
1219
  template_source,
@@ -305,10 +1223,9 @@ module Ruby
305
1223
  )
306
1224
  end
307
1225
 
308
- def analyze_ruby_document(source)
1226
+ def analyze_ruby_document(source, process_analysis: nil)
309
1227
  lines = normalize_source(source).split("\n", -1)
310
- requires = []
311
- declarations = []
1228
+ requires = ruby_analysis_require_owners(source, process_analysis)
312
1229
  discovered_surfaces = []
313
1230
  pending_comments = []
314
1231
 
@@ -326,23 +1243,15 @@ module Ruby
326
1243
  next
327
1244
  end
328
1245
 
329
- if (match = REQUIRE_PATTERN.match(line))
330
- requires << {
331
- path: "/requires/#{requires.length}",
332
- owner_kind: "require",
333
- match_key: match[1]
334
- }
1246
+ if ruby_process_import_item_at_line(process_analysis,
1247
+ line_number) || legacy_require_line?(line, process_analysis)
335
1248
  pending_comments = []
336
1249
  next
337
1250
  end
338
1251
 
339
- declaration = declaration_for_line(line)
1252
+ declaration = ruby_process_structure_item_at_line(process_analysis, line_number)
1253
+ declaration ||= declaration_for_line(line) if Array(process_analysis&.structure).empty?
340
1254
  if declaration
341
- declarations << {
342
- path: "/declarations/#{declaration[:name]}",
343
- owner_kind: "declaration",
344
- match_key: declaration[:name]
345
- }
346
1255
  surfaces = surfaces_for_owner(
347
1256
  owner_name: declaration[:name],
348
1257
  comment_entries: pending_comments
@@ -355,25 +1264,29 @@ module Ruby
355
1264
  pending_comments = []
356
1265
  end
357
1266
 
1267
+ declaration_entries = ruby_process_owner_entries(process_analysis)
1268
+ declaration_entries = legacy_ruby_analysis_owner_entries(source) if declaration_entries.empty?
1269
+ declarations = declaration_entries.map do |entry|
1270
+ {
1271
+ path: entry[:path],
1272
+ owner_kind: 'declaration',
1273
+ match_key: entry[:name]
1274
+ }
1275
+ end
1276
+
358
1277
  {
359
- kind: "ruby",
360
- dialect: "ruby",
361
- root_kind: "document",
1278
+ kind: 'ruby',
1279
+ dialect: 'ruby',
1280
+ root_kind: 'document',
362
1281
  source: normalize_source(source),
1282
+ tree_haver_process_analysis: process_analysis,
363
1283
  owners: (requires + declarations).sort_by { |owner| owner[:path] },
364
- discovered_surfaces: discovered_surfaces
1284
+ discovered_surfaces: discovered_surfaces,
1285
+ method_shadowing: ruby_method_shadowing(source),
1286
+ diagnostics: ruby_method_shadowing_diagnostics(source)
365
1287
  }
366
1288
  end
367
1289
 
368
- def collect_ruby_require_entries(source)
369
- normalize_source(source).split("\n").filter_map do |line|
370
- match = REQUIRE_PATTERN.match(line)
371
- next unless match
372
-
373
- { path: "/requires/#{match[1]}", text: line.rstrip }
374
- end
375
- end
376
-
377
1290
  def collect_ruby_preamble(source)
378
1291
  lines = normalize_source(source).split("\n")
379
1292
  preamble = []
@@ -385,98 +1298,1361 @@ module Ruby
385
1298
  preamble.join("\n").strip
386
1299
  end
387
1300
 
388
- def collect_top_level_dsl_entries(source)
389
- lines = normalize_source(source).split("\n")
390
- entries = []
391
- pending_comments = []
392
- index = 0
1301
+ def ruby_file_footer_text(source)
1302
+ regions = ruby_source_regions(source).fetch(:regions)
1303
+ footer = regions.reverse.find do |region|
1304
+ region[:region_kind] == 'interstitial' && region[:position] == 'file_footer'
1305
+ end
1306
+ content = footer.to_h.fetch(:content, '').strip
1307
+ return '' if content.empty?
393
1308
 
394
- while index < lines.length
395
- line = lines[index]
396
- stripped = line.strip
397
- if comment_line?(line)
398
- pending_comments << index
399
- index += 1
400
- next
401
- end
402
- if stripped.empty?
403
- pending_comments = []
1309
+ content.lines.any? { |line| !line.strip.empty? && !comment_line?(line) } ? '' : content
1310
+ end
1311
+
1312
+ def collect_parse_errors(node)
1313
+ raise TreeHaver::NotAvailable, 'Ruby parse returned no root node' unless node
1314
+ return unless node.respond_to?(:has_error?) && node.has_error?
1315
+
1316
+ raise TreeHaver::NotAvailable,
1317
+ 'Ruby parse contains syntax errors'
1318
+ end
1319
+
1320
+ def parse_failure_result(error)
1321
+ {
1322
+ ok: false,
1323
+ diagnostics: [{ severity: 'error', category: 'parse_error', message: error.message }],
1324
+ policies: []
1325
+ }
1326
+ end
1327
+
1328
+ def ruby_process_analysis_from_tree(source, root_node)
1329
+ structure = []
1330
+ imports = []
1331
+
1332
+ ruby_named_children(root_node).each do |node|
1333
+ case node.type
1334
+ when 'call'
1335
+ import = ruby_import_item_from_node(source, node)
1336
+ imports << import if import
1337
+ when 'if_modifier'
1338
+ import = ruby_import_item_from_modifier_node(source, node)
1339
+ imports << import if import
1340
+ when 'begin'
1341
+ import = ruby_import_item_from_begin_node(source, node)
1342
+ imports << import if import
1343
+ when 'class', 'module', 'method', 'singleton_method'
1344
+ item = ruby_structure_item_from_node(source, node)
1345
+ structure << item if item
1346
+ end
1347
+ end
1348
+
1349
+ TslpProcessAnalysis.new(structure: structure, imports: imports)
1350
+ end
1351
+
1352
+ def ruby_import_item_from_node(source, node)
1353
+ children = ruby_named_children(node)
1354
+ callee = children.first
1355
+ return unless callee && %w[identifier method_identifier].include?(callee.type)
1356
+
1357
+ name = ruby_node_text(source, callee)
1358
+ return unless %w[require require_relative].include?(name)
1359
+
1360
+ string_node = ruby_first_descendant(node) do |child|
1361
+ %w[string_content simple_symbol].include?(child.type)
1362
+ end
1363
+ return unless string_node
1364
+
1365
+ TslpImportItem.new(source: ruby_node_text(source, string_node), span: ruby_import_span_for(source, node))
1366
+ end
1367
+
1368
+ def ruby_import_item_from_modifier_node(source, node)
1369
+ call_node = ruby_named_children(node).first
1370
+ return unless call_node&.type == 'call'
1371
+
1372
+ import = ruby_import_item_from_node(source, call_node)
1373
+ return unless import
1374
+
1375
+ TslpImportItem.new(source: import.source, span: ruby_import_span_for(source, node))
1376
+ end
1377
+
1378
+ def ruby_import_item_from_begin_node(source, node)
1379
+ children = ruby_named_children(node)
1380
+ call_imports = children.filter_map do |child|
1381
+ ruby_import_item_from_node(source, child) if child.type == 'call'
1382
+ end
1383
+ return if call_imports.empty?
1384
+
1385
+ unsupported = children.any? do |child|
1386
+ child.type != 'call' && !ruby_load_error_rescue_node?(source, child)
1387
+ end
1388
+ return if unsupported
1389
+
1390
+ TslpImportItem.new(source: call_imports.map(&:source).join(','), span: ruby_span_for(node))
1391
+ end
1392
+
1393
+ def ruby_load_error_rescue_node?(source, node)
1394
+ return false unless node.type == 'rescue'
1395
+ return false unless ruby_named_children(node).all? { |child| child.type == 'exceptions' }
1396
+
1397
+ ruby_node_text(source, node).match?(/\Arescue\s+LoadError\b/)
1398
+ end
1399
+
1400
+ def ruby_structure_item_from_node(source, node)
1401
+ kind = case node.type
1402
+ when 'class'
1403
+ 'class'
1404
+ when 'module'
1405
+ 'module'
1406
+ when 'method', 'singleton_method'
1407
+ 'method'
1408
+ end
1409
+ return unless kind
1410
+
1411
+ name_node = ruby_declaration_name_node(node)
1412
+ return unless name_node
1413
+
1414
+ TslpStructureItem.new(kind: kind, name: ruby_node_text(source, name_node), span: ruby_span_for(node))
1415
+ end
1416
+
1417
+ def ruby_declaration_name_node(node)
1418
+ case node.type
1419
+ when 'class', 'module'
1420
+ ruby_named_children(node).find do |child|
1421
+ %w[constant scope_resolution].include?(child.type)
1422
+ end
1423
+ when 'method'
1424
+ ruby_named_children(node).find do |child|
1425
+ %w[identifier method_identifier operator].include?(child.type)
1426
+ end
1427
+ when 'singleton_method'
1428
+ children = ruby_named_children(node)
1429
+ children.reverse.find do |child|
1430
+ %w[identifier method_identifier operator].include?(child.type)
1431
+ end
1432
+ end
1433
+ end
1434
+
1435
+ def ruby_first_descendant(node, &block)
1436
+ ruby_named_children(node).each do |child|
1437
+ return child if yield(child)
1438
+
1439
+ descendant = ruby_first_descendant(child, &block)
1440
+ return descendant if descendant
1441
+ end
1442
+ nil
1443
+ end
1444
+
1445
+ def ruby_named_children(node)
1446
+ node.children.select { |child| !child.respond_to?(:named?) || child.named? }
1447
+ end
1448
+
1449
+ def ruby_span_for(node)
1450
+ start_point = node.start_point
1451
+ end_point = node.end_point
1452
+ TslpSpan.new(
1453
+ start_row: start_point.fetch(:row),
1454
+ start_col: start_point.fetch(:column),
1455
+ end_row: end_point.fetch(:row),
1456
+ end_col: end_point.fetch(:column)
1457
+ )
1458
+ end
1459
+
1460
+ def ruby_import_span_for(source, node)
1461
+ span = ruby_span_for(node)
1462
+ lines = normalize_source(source).split("\n")
1463
+ start_row = span.start_row
1464
+ end_row = span.end_row
1465
+
1466
+ start_row -= 1 while start_row.positive? && coverage_directive_comment_line?(lines[start_row - 1])
1467
+ end_row += 1 while end_row < lines.length - 1 && coverage_directive_comment_line?(lines[end_row + 1])
1468
+
1469
+ TslpSpan.new(
1470
+ start_row: start_row,
1471
+ start_col: start_row == span.start_row ? span.start_col : 0,
1472
+ end_row: end_row,
1473
+ end_col: end_row == span.end_row ? span.end_col : lines[end_row].to_s.length
1474
+ )
1475
+ end
1476
+
1477
+ def coverage_directive_comment_line?(line)
1478
+ BlockDirectiveDetector.coverage_directive_line?(line)
1479
+ end
1480
+
1481
+ def ruby_node_text(source, node)
1482
+ source[node.start_byte...node.end_byte].to_s
1483
+ end
1484
+
1485
+ def ruby_fallback_scope_rank(scope)
1486
+ ruby_fallback_policy_profile.fetch(:scopes).index(scope.to_s) || Float::INFINITY
1487
+ end
1488
+
1489
+ def stable_owner_signatures(owner_identities)
1490
+ owner_identities.map do |identity|
1491
+ {
1492
+ owner_kind: identity.fetch(:owner_kind),
1493
+ owner_name: identity.fetch(:owner_name),
1494
+ parent_scope: identity.fetch(:parent_scope),
1495
+ structural_identity: identity.fetch(:structural_identity),
1496
+ occurrence_index: identity.fetch(:occurrence_index)
1497
+ }
1498
+ end
1499
+ end
1500
+
1501
+ def significant_source_lines(source)
1502
+ normalize_source(source).lines.map(&:strip).reject do |line|
1503
+ line.empty? || line.start_with?('#') || line == 'end'
1504
+ end
1505
+ end
1506
+
1507
+ def merge_ruby_requires(destination_requires, template_requires)
1508
+ destination_paths = destination_requires.to_h { |entry| [entry[:path], true] }
1509
+ destination_requires + template_requires.reject { |entry| destination_paths[entry[:path]] }
1510
+ end
1511
+
1512
+ def ruby_tslp_merge_context(analysis, role:)
1513
+ source = analysis.fetch(:source)
1514
+ process_analysis = analysis[:tree_haver_process_analysis]
1515
+ unsupported_lines = ruby_tslp_unsupported_top_level_lines(source, process_analysis)
1516
+ unless unsupported_lines.empty?
1517
+ return unsupported_feature_result(
1518
+ "ruby-merge can only merge TSLP-record-backed top-level Ruby declarations and imports; #{role} has unsupported top-level content on line(s) #{unsupported_lines.join(', ')}. Use prism-merge for native Ruby merging, or report missing Ruby process records to tree-sitter-language-pack."
1519
+ )
1520
+ end
1521
+
1522
+ {
1523
+ ok: true,
1524
+ source: source,
1525
+ preamble: ruby_tslp_file_preamble_text(source, process_analysis),
1526
+ requires: ruby_process_import_entries(source, process_analysis),
1527
+ declarations: collect_ruby_declaration_entries(source, process_analysis: process_analysis),
1528
+ footer: ruby_tslp_file_footer_text(source, process_analysis)
1529
+ }
1530
+ end
1531
+
1532
+ def ruby_tslp_unsupported_top_level_lines(source, process_analysis)
1533
+ lines = normalize_source(source).split("\n", -1)
1534
+ claimed = ruby_tslp_claimed_line_indexes(lines, process_analysis)
1535
+ lines.each_index.filter_map do |index|
1536
+ next if claimed.include?(index)
1537
+
1538
+ line = lines[index]
1539
+ next if line.strip.empty? || comment_line?(line)
1540
+
1541
+ index + 1
1542
+ end
1543
+ end
1544
+
1545
+ def ruby_tslp_claimed_line_indexes(lines, process_analysis)
1546
+ claimed = Set.new
1547
+ ruby_top_level_process_structure_items(process_analysis).each do |item|
1548
+ start_index = attached_comment_start_index(lines, item.span.start_row)
1549
+ (start_index..item.span.end_row).each { |line_index| claimed.add(line_index) }
1550
+ end
1551
+ Array(process_analysis&.imports).each do |item|
1552
+ (item.span.start_row..item.span.end_row).each { |line_index| claimed.add(line_index) }
1553
+ end
1554
+ claimed
1555
+ end
1556
+
1557
+ def ruby_process_import_entries(source, process_analysis)
1558
+ lines = normalize_source(source).split("\n")
1559
+ Array(process_analysis&.imports).map do |item|
1560
+ text = lines[item.span.start_row..item.span.end_row].to_a.join("\n").rstrip
1561
+ {
1562
+ path: "/requires/#{item.source}",
1563
+ text: text,
1564
+ start_index: item.span.start_row,
1565
+ end_index: item.span.end_row
1566
+ }
1567
+ end
1568
+ end
1569
+
1570
+ def ruby_analysis_require_owners(source, process_analysis)
1571
+ imports = Array(process_analysis&.imports)
1572
+ unless imports.empty?
1573
+ return imports.each_with_index.map do |item, index|
1574
+ {
1575
+ path: "/requires/#{index}",
1576
+ owner_kind: 'require',
1577
+ match_key: item.source.to_s
1578
+ }
1579
+ end
1580
+ end
1581
+
1582
+ return [] if process_analysis
1583
+
1584
+ legacy_ruby_require_owners(source)
1585
+ end
1586
+
1587
+ def legacy_ruby_require_owners(source)
1588
+ requires = []
1589
+ normalize_source(source).split("\n").each do |line|
1590
+ match = REQUIRE_PATTERN.match(line)
1591
+ next unless match
1592
+
1593
+ requires << {
1594
+ path: "/requires/#{requires.length}",
1595
+ owner_kind: 'require',
1596
+ match_key: match[1]
1597
+ }
1598
+ end
1599
+ requires
1600
+ end
1601
+
1602
+ def ruby_process_import_item_at_line(process_analysis, line_number)
1603
+ index = line_number.to_i - 1
1604
+ Array(process_analysis&.imports).find do |item|
1605
+ item.span.start_row <= index && item.span.end_row >= index
1606
+ end
1607
+ end
1608
+
1609
+ def legacy_require_line?(line, process_analysis)
1610
+ return false if process_analysis
1611
+
1612
+ REQUIRE_PATTERN.match?(line)
1613
+ end
1614
+
1615
+ def ruby_tslp_file_footer_text(source, process_analysis)
1616
+ lines = normalize_source(source).split("\n")
1617
+ claimed = ruby_tslp_claimed_line_indexes(lines, process_analysis)
1618
+ footer_indexes = []
1619
+ (lines.length - 1).downto(0) do |index|
1620
+ break if claimed.include?(index)
1621
+ break unless lines[index].strip.empty? || comment_line?(lines[index])
1622
+
1623
+ footer_indexes.unshift(index)
1624
+ end
1625
+ lines.values_at(*footer_indexes).join("\n").strip
1626
+ end
1627
+
1628
+ def ruby_tslp_file_preamble_text(source, process_analysis)
1629
+ lines = normalize_source(source).split("\n")
1630
+ claimed = ruby_tslp_claimed_line_indexes(lines, process_analysis)
1631
+ preamble_indexes = []
1632
+ lines.each_index do |index|
1633
+ break if claimed.include?(index)
1634
+ break unless lines[index].strip.empty? || comment_line?(lines[index])
1635
+
1636
+ preamble_indexes << index
1637
+ end
1638
+ lines.values_at(*preamble_indexes).join("\n").strip
1639
+ end
1640
+
1641
+ def collect_ruby_declaration_entries(source, process_analysis: nil)
1642
+ process_entries = ruby_process_declaration_entries(source, process_analysis: process_analysis)
1643
+ return process_entries unless process_entries.empty?
1644
+
1645
+ legacy_collect_ruby_declaration_entries(source)
1646
+ end
1647
+
1648
+ def legacy_collect_ruby_declaration_entries(source)
1649
+ # TSLP process records are the preferred substrate. This legacy scanner is
1650
+ # retained only for direct helper calls that do not have parser analysis.
1651
+ # Main merge paths must pass process_analysis and fail closed when TSLP
1652
+ # cannot provide readable structure records.
1653
+ lines = normalize_source(source).split("\n")
1654
+ entries = []
1655
+ pending_comments = []
1656
+ index = 0
1657
+
1658
+ while index < lines.length
1659
+ line = lines[index]
1660
+ stripped = line.strip
1661
+
1662
+ if comment_line?(line)
1663
+ pending_comments << index
1664
+ index += 1
1665
+ next
1666
+ end
1667
+
1668
+ if stripped.empty?
1669
+ pending_comments = []
1670
+ index += 1
1671
+ next
1672
+ end
1673
+
1674
+ if REQUIRE_PATTERN.match?(line)
1675
+ pending_comments = []
1676
+ index += 1
1677
+ next
1678
+ end
1679
+
1680
+ declaration = declaration_for_line(line)
1681
+ unless declaration
1682
+ pending_comments = []
1683
+ index += 1
1684
+ next
1685
+ end
1686
+
1687
+ start_index = pending_comments.first || index
1688
+ depth = 1
1689
+ cursor = index + 1
1690
+ while cursor < lines.length
1691
+ candidate = lines[cursor].strip
1692
+ depth += 1 if declaration_for_line(candidate)
1693
+ if candidate == 'end'
1694
+ depth -= 1
1695
+ if depth.zero?
1696
+ cursor += 1
1697
+ break
1698
+ end
1699
+ end
1700
+ cursor += 1
1701
+ end
1702
+
1703
+ entries << {
1704
+ path: "/declarations/#{declaration[:name]}",
1705
+ name: declaration[:name],
1706
+ kind: declaration[:kind],
1707
+ merge_key: "#{declaration[:kind]}:#{declaration[:name]}",
1708
+ text: lines[start_index...cursor].join("\n").strip
1709
+ }
1710
+ pending_comments = []
1711
+ index = cursor
1712
+ end
1713
+
1714
+ entries
1715
+ end
1716
+
1717
+ def legacy_ruby_analysis_owner_entries(source)
1718
+ normalize_source(source).split("\n").filter_map do |line|
1719
+ declaration = declaration_for_line(line)
1720
+ next unless declaration
1721
+
1722
+ {
1723
+ path: "/declarations/#{declaration[:name]}",
1724
+ name: declaration[:name],
1725
+ kind: declaration[:kind],
1726
+ merge_key: "#{declaration[:kind]}:#{declaration[:name]}"
1727
+ }
1728
+ end
1729
+ end
1730
+
1731
+ def ruby_process_owner_entries(process_analysis)
1732
+ Array(process_analysis&.structure).filter_map do |item|
1733
+ kind = ruby_process_owner_kind(item)
1734
+ name = item.name.to_s
1735
+ next if kind.to_s.empty? || name.empty?
1736
+
1737
+ {
1738
+ path: "/declarations/#{name}",
1739
+ name: name,
1740
+ kind: kind,
1741
+ merge_key: "#{kind}:#{name}"
1742
+ }
1743
+ end
1744
+ end
1745
+
1746
+ def ruby_process_declaration_entries(source, process_analysis: nil)
1747
+ items = ruby_top_level_process_structure_items(process_analysis)
1748
+ return [] if items.empty?
1749
+
1750
+ lines = normalize_source(source).split("\n")
1751
+ items.map do |item|
1752
+ start_index = attached_comment_start_index(lines, item.span.start_row)
1753
+ finish_index = item.span.end_row
1754
+ kind = ruby_process_structure_kind(item)
1755
+ name = item.name.to_s
1756
+ {
1757
+ path: "/declarations/#{name}",
1758
+ name: name,
1759
+ kind: kind,
1760
+ merge_key: "#{kind}:#{name}",
1761
+ text: lines[start_index..finish_index].to_a.join("\n").strip,
1762
+ start_index: start_index,
1763
+ end_index: finish_index
1764
+ }
1765
+ end
1766
+ end
1767
+
1768
+ def ruby_top_level_section(text, entries)
1769
+ positioned_entries = entries.select do |entry|
1770
+ entry[:start_index].is_a?(Integer) && entry[:end_index].is_a?(Integer)
1771
+ end
1772
+ return { text: text } if positioned_entries.empty?
1773
+
1774
+ {
1775
+ text: text,
1776
+ start_index: positioned_entries.map { |entry| entry[:start_index] }.min,
1777
+ end_index: positioned_entries.map { |entry| entry[:end_index] }.max
1778
+ }
1779
+ end
1780
+
1781
+ def emit_ruby_top_level_sections(destination_source, sections)
1782
+ lines = normalize_source(destination_source).split("\n", -1)
1783
+ emitted = sections.reject { |section| section[:text].to_s.strip.empty? }
1784
+ previous = nil
1785
+ output = +''
1786
+
1787
+ emitted.each do |section|
1788
+ output << ruby_top_level_section_separator(lines, previous, section) if previous
1789
+ output << section.fetch(:text).strip
1790
+ previous = section
1791
+ end
1792
+
1793
+ "#{output.strip}\n"
1794
+ end
1795
+
1796
+ def ruby_top_level_section_separator(lines, previous, current)
1797
+ return "\n\n" unless previous[:end_index].is_a?(Integer) && current[:start_index].is_a?(Integer)
1798
+ return "\n\n" unless current[:start_index] > previous[:end_index]
1799
+
1800
+ gap = lines[(previous[:end_index] + 1)...current[:start_index]].to_a
1801
+ return "\n\n" if gap.empty?
1802
+
1803
+ "\n#{gap.join("\n")}\n"
1804
+ end
1805
+
1806
+ def ruby_top_level_process_structure_items(process_analysis)
1807
+ items = Array(process_analysis&.structure).select do |item|
1808
+ ruby_process_structure_kind(item) && !item.name.to_s.empty?
1809
+ end
1810
+ items.reject do |item|
1811
+ items.any? do |candidate|
1812
+ next false if candidate.equal?(item)
1813
+
1814
+ candidate.span.start_row <= item.span.start_row &&
1815
+ candidate.span.end_row >= item.span.end_row &&
1816
+ (candidate.span.start_row < item.span.start_row || candidate.span.end_row > item.span.end_row)
1817
+ end
1818
+ end.sort_by { |item| [item.span.start_row, item.span.start_col] }
1819
+ end
1820
+
1821
+ def ruby_process_structure_item_at_line(process_analysis, line_number)
1822
+ Array(process_analysis&.structure).find do |item|
1823
+ ruby_process_owner_kind(item) &&
1824
+ item.span.start_row == line_number - 1
1825
+ end&.then do |item|
1826
+ { kind: ruby_process_owner_kind(item), name: item.name.to_s }
1827
+ end
1828
+ end
1829
+
1830
+ def ruby_process_owner_kind(item)
1831
+ case item.kind.to_s
1832
+ when 'class'
1833
+ 'class'
1834
+ when 'module'
1835
+ 'module'
1836
+ when 'method', 'function'
1837
+ 'def'
1838
+ end
1839
+ end
1840
+
1841
+ def ruby_process_structure_kind(item)
1842
+ case item.kind.to_s
1843
+ when 'class'
1844
+ 'class'
1845
+ when 'module'
1846
+ 'module'
1847
+ when 'method', 'function'
1848
+ 'def'
1849
+ end
1850
+ end
1851
+
1852
+ def attached_comment_start_index(lines, declaration_index)
1853
+ index = declaration_index.to_i
1854
+ index -= 1 while index.positive? && comment_line?(lines[index - 1])
1855
+ index
1856
+ end
1857
+
1858
+ def merge_ruby_declaration_entry(template_entry, destination_entry)
1859
+ return destination_entry unless template_entry
1860
+
1861
+ merged_text = merge_declaration_hash_constants(template_entry[:text], destination_entry[:text])
1862
+ merged_text = merge_declaration_body_constants(template_entry[:text], merged_text)
1863
+ merged_text = merge_declaration_body_methods(template_entry[:text], merged_text)
1864
+ merged_text = merge_nested_body_declarations(template_entry[:text], merged_text)
1865
+ destination_entry.merge(
1866
+ text: merged_text
1867
+ )
1868
+ end
1869
+
1870
+ def ruby_intra_owner_merge_plan(template_entries, destination_entries)
1871
+ template_by_key = template_entries.to_h { |entry| [entry[:merge_key], entry] }
1872
+ destination_entries.flat_map do |destination_entry|
1873
+ template_entry = template_by_key[destination_entry[:merge_key]]
1874
+ next [] unless template_entry
1875
+ next [] unless %w[class module].include?(destination_entry[:kind])
1876
+
1877
+ template_methods = direct_body_method_entries(template_entry[:text]).to_h { |entry| [entry[:signature], entry] }
1878
+ direct_body_method_entries(destination_entry[:text]).filter_map do |destination_method|
1879
+ template_method = template_methods[destination_method[:signature]]
1880
+ next unless template_method
1881
+ next if template_method[:body_text] == destination_method[:body_text]
1882
+
1883
+ {
1884
+ owner_path: destination_entry[:path],
1885
+ owner_kind: destination_entry[:kind],
1886
+ owner_name: destination_entry[:name],
1887
+ child_group: 'methods',
1888
+ child_signature: destination_method[:signature],
1889
+ child_path: "#{destination_entry[:path]}/methods/#{destination_method[:signature]}",
1890
+ decision: 'destination_wins',
1891
+ scope: 'owner_body'
1892
+ }
1893
+ end
1894
+ end
1895
+ end
1896
+
1897
+ def ruby_namespace_form_conflicts(template_entries, destination_entries)
1898
+ destination_names = destination_entries.to_h do |entry|
1899
+ ["#{entry[:kind]}:#{entry[:name]}", true]
1900
+ end
1901
+ template_entries.flat_map do |entry|
1902
+ direct_body_declaration_entries(entry[:text]).filter_map do |nested_entry|
1903
+ compact_key = "#{nested_entry[:kind]}:#{entry[:name]}::#{nested_entry[:name]}"
1904
+ next unless destination_names[compact_key]
1905
+
1906
+ "#{entry[:name]}::#{nested_entry[:name]}"
1907
+ end
1908
+ end.uniq
1909
+ end
1910
+
1911
+ def source_owner_identity_entry(kind:, name:, parent_scope:, address:, content:)
1912
+ normalized_kind = kind.to_s
1913
+ normalized_name = name.to_s
1914
+ {
1915
+ owner_kind: normalized_kind,
1916
+ owner_name: normalized_name,
1917
+ parent_scope: parent_scope,
1918
+ address: address,
1919
+ structural_identity: "#{parent_scope}:#{normalized_kind}:#{normalized_name}",
1920
+ content_identity: "sha256:#{Digest::SHA256.hexdigest(content.to_s)}",
1921
+ identity_components: %w[owner_kind owner_name parent_scope content_identity]
1922
+ }
1923
+ end
1924
+
1925
+ def add_source_owner_occurrence_indexes(identities)
1926
+ counters = Hash.new(0)
1927
+ identities.map do |identity|
1928
+ occurrence_index = counters[identity[:structural_identity]]
1929
+ counters[identity[:structural_identity]] += 1
1930
+ identity.merge(
1931
+ occurrence_index: occurrence_index,
1932
+ address: occurrence_index.zero? ? identity[:address] : "#{identity[:address]}[#{occurrence_index}]"
1933
+ )
1934
+ end
1935
+ end
1936
+
1937
+ def ruby_method_identity_entries(source)
1938
+ collect_ruby_declaration_entries(source).flat_map do |declaration_entry|
1939
+ direct_body_method_entries(declaration_entry[:text]).map do |method_entry|
1940
+ {
1941
+ parent_scope: declaration_entry[:path],
1942
+ signature: method_entry[:signature],
1943
+ address: "#{declaration_entry[:path]}/methods/#{method_entry[:signature]}",
1944
+ normalized_body_identity: normalized_method_body_identity(method_entry[:body_text])
1945
+ }
1946
+ end
1947
+ end
1948
+ end
1949
+
1950
+ def normalized_method_body_identity(body_text)
1951
+ normalized_lines = body_text.to_s.lines.map.with_index do |line, index|
1952
+ index.zero? && DEF_PATTERN.match?(line) ? "#{line[/\A\s*/]}def __owner_name__\n" : line
1953
+ end
1954
+ "sha256:#{Digest::SHA256.hexdigest(normalized_lines.join)}"
1955
+ end
1956
+
1957
+ def ruby_method_shadowing(source)
1958
+ collect_ruby_declaration_entries(source).flat_map do |entry|
1959
+ direct_method_shadowing(entry)
1960
+ end
1961
+ end
1962
+
1963
+ def ruby_method_shadowing_diagnostics(source)
1964
+ ruby_method_shadowing(source).map do |entry|
1965
+ {
1966
+ severity: 'warning',
1967
+ category: 'ruby_method_shadowing',
1968
+ path: "#{entry[:owner_path]}/methods/#{entry[:method_signature]}",
1969
+ message: "Ruby method #{entry[:method_signature]} is defined #{entry[:shadowed_count] + 1} times in #{entry[:owner_path]}; the last definition shadows earlier definitions."
1970
+ }
1971
+ end
1972
+ end
1973
+
1974
+ def direct_method_shadowing(declaration_entry)
1975
+ grouped = direct_body_method_entries(declaration_entry[:text]).each_with_index.group_by do |(method_entry, _index)|
1976
+ method_entry[:signature]
1977
+ end
1978
+
1979
+ grouped.filter_map do |signature, entries|
1980
+ next if entries.length < 2
1981
+
1982
+ {
1983
+ owner_path: declaration_entry[:path],
1984
+ method_signature: signature,
1985
+ effective_index: entries.last[1],
1986
+ shadowed_indices: entries[0...-1].map { |_method_entry, index| index },
1987
+ shadowed_count: entries.length - 1
1988
+ }
1989
+ end
1990
+ end
1991
+
1992
+ def ruby_method_projection(source, revision:)
1993
+ collect_ruby_declaration_entries(source).flat_map do |declaration_entry|
1994
+ direct_body_method_entries(declaration_entry[:text]).each_with_index.map do |method_entry, index|
1995
+ signature = "method:#{declaration_entry[:path]}:#{method_entry[:signature]}"
1996
+ {
1997
+ path: "#{declaration_entry[:path]}/methods/#{index}",
1998
+ parent_path: "#{declaration_entry[:path]}/methods",
1999
+ node_id: "#{revision}:#{signature}",
2000
+ signature: signature,
2001
+ index: index
2002
+ }
2003
+ end
2004
+ end
2005
+ end
2006
+
2007
+ def qualified_nested_declaration_entries(entries)
2008
+ entries.flat_map do |entry|
2009
+ direct_body_declaration_entries(entry[:text]).map do |nested_entry|
2010
+ root_name = entry[:name]
2011
+ nested_name = nested_entry[:name]
2012
+ qualified_name = nested_name.include?('::') ? nested_name : "#{root_name}::#{nested_name}"
2013
+ nested_entry.merge(
2014
+ name: qualified_name,
2015
+ path: "/declarations/#{qualified_name}",
2016
+ merge_key: "#{nested_entry[:kind]}:#{qualified_name}",
2017
+ text: normalize_declaration_text_indent(nested_entry[:text]),
2018
+ namespace_root_merge_key: entry[:merge_key]
2019
+ )
2020
+ end
2021
+ end
2022
+ end
2023
+
2024
+ def normalize_declaration_text_indent(text)
2025
+ lines = text.to_s.split("\n")
2026
+ base_indent = lines.first.to_s[/\A\s*/].to_s
2027
+ return text if base_indent.empty?
2028
+
2029
+ lines.map do |line|
2030
+ line.start_with?(base_indent) ? line[base_indent.length..].to_s : line
2031
+ end.join("\n")
2032
+ end
2033
+
2034
+ def namespace_wrapper_matched?(entry, candidates, matched)
2035
+ children = candidates.select { |candidate| candidate[:namespace_root_merge_key] == entry[:merge_key] }
2036
+ return false if children.empty?
2037
+ unless direct_body_method_entries(entry[:text]).empty? && direct_body_constant_entries(entry[:text]).empty?
2038
+ return false
2039
+ end
2040
+
2041
+ children.all? { |child| matched[child[:merge_key]] }
2042
+ end
2043
+
2044
+ def merge_declaration_hash_constants(template_text, destination_text)
2045
+ template_blocks = constant_hash_blocks(template_text).to_h { |block| [block[:constant], block] }
2046
+ destination_blocks = constant_hash_blocks(destination_text)
2047
+ return destination_text if template_blocks.empty? || destination_blocks.empty?
2048
+
2049
+ output = destination_text.dup
2050
+ destination_blocks.reverse_each do |destination_block|
2051
+ template_block = template_blocks[destination_block[:constant]]
2052
+ next unless template_block
2053
+
2054
+ template_hash = RubyHashLiteralProjector.new(template_block[:hash_source]).call
2055
+ destination_hash = RubyHashLiteralProjector.new(destination_block[:hash_source]).call
2056
+ merged_hash = merge_ruby_hash_literals(template_hash, destination_hash)
2057
+ rendered = "#{destination_block[:prefix]}#{render_ruby_hash_literal(merged_hash,
2058
+ destination_block[:base_indent])}"
2059
+ output[destination_block[:range]] = rendered
2060
+ rescue ArgumentError
2061
+ next
2062
+ end
2063
+ output
2064
+ end
2065
+
2066
+ def merge_declaration_body_constants(template_text, destination_text)
2067
+ template_constants = direct_body_constant_entries(template_text)
2068
+ destination_constants = direct_body_constant_entries(destination_text)
2069
+ return destination_text if template_constants.empty?
2070
+
2071
+ merged_text = merge_matched_array_constants(template_constants, destination_constants, destination_text)
2072
+ destination_names = destination_constants.map { |entry| entry[:name] }.to_h { |name| [name, true] }
2073
+ missing_constants = template_constants.reject { |entry| destination_names[entry[:name]] }
2074
+ return merged_text if missing_constants.empty?
2075
+
2076
+ insert_declaration_body_blocks(merged_text, missing_constants.map do |entry|
2077
+ entry[:text]
2078
+ end, placement: :after_opening)
2079
+ end
2080
+
2081
+ def merge_matched_array_constants(template_constants, destination_constants, destination_text)
2082
+ template_by_name = template_constants.to_h { |entry| [entry[:name], entry] }
2083
+ output = destination_text.dup
2084
+ destination_constants.reverse_each do |destination_entry|
2085
+ template_entry = template_by_name[destination_entry[:name]]
2086
+ next unless template_entry
2087
+
2088
+ merged_text = merge_array_constant_text(template_entry[:text], destination_entry[:text])
2089
+ next unless merged_text
2090
+
2091
+ output[destination_entry[:range]] = merged_text
2092
+ end
2093
+ output
2094
+ end
2095
+
2096
+ def merge_array_constant_text(template_text, destination_text)
2097
+ template_match = template_text.match(/\A(\s*[A-Z]\w*\s*=\s*)\[(.*)\]\z/)
2098
+ destination_match = destination_text.match(/\A(\s*[A-Z]\w*\s*=\s*)\[(.*)\]\z/)
2099
+ unless template_match && destination_match
2100
+ return merge_percent_array_constant_text(template_text,
2101
+ destination_text) || merge_multiline_array_constant_text(template_text,
2102
+ destination_text)
2103
+ end
2104
+
2105
+ destination_elements = split_ruby_array_elements(destination_match[2])
2106
+ template_elements = split_ruby_array_elements(template_match[2])
2107
+ destination_keys = destination_elements.map do |element|
2108
+ normalize_array_element_key(element)
2109
+ end.to_h { |key| [key, true] }
2110
+ appended = template_elements.reject { |element| destination_keys[normalize_array_element_key(element)] }
2111
+ return destination_text if appended.empty?
2112
+
2113
+ "#{destination_match[1]}[#{(destination_elements + appended).join(', ')}]"
2114
+ end
2115
+
2116
+ def merge_percent_array_constant_text(template_text, destination_text)
2117
+ template_match = parse_percent_array_constant_text(template_text)
2118
+ destination_match = parse_percent_array_constant_text(destination_text)
2119
+ return unless template_match && destination_match
2120
+
2121
+ destination_elements = destination_match[:body].split(/\s+/).reject(&:empty?)
2122
+ template_elements = template_match[:body].split(/\s+/).reject(&:empty?)
2123
+ destination_keys = destination_elements.to_h { |element| [element, true] }
2124
+ appended = template_elements.reject { |element| destination_keys[element] }
2125
+ return destination_text if appended.empty?
2126
+
2127
+ "#{destination_match[:prefix]}#{(destination_elements + appended).join(' ')}#{destination_match[:closing]}"
2128
+ end
2129
+
2130
+ def parse_percent_array_constant_text(text)
2131
+ match = text.match(/\A(?<head>\s*[A-Z]\w*\s*=\s*%[wWiI])(?<opening>[^\s[:alnum:]])(?<content>.*)\z/)
2132
+ return unless match
2133
+
2134
+ closing = PERCENT_ARRAY_DELIMITER_PAIRS.fetch(match[:opening], match[:opening])
2135
+ content = match[:content]
2136
+ return unless content.end_with?(closing)
2137
+
2138
+ {
2139
+ prefix: "#{match[:head]}#{match[:opening]}",
2140
+ body: content[0...-closing.length],
2141
+ closing: closing
2142
+ }
2143
+ end
2144
+
2145
+ def merge_multiline_array_constant_text(template_text, destination_text)
2146
+ template_match = template_text.match(/\A(\s*[A-Z]\w*\s*=\s*\[\n)(.*)(\n\s*\])\z/m)
2147
+ destination_match = destination_text.match(/\A(\s*[A-Z]\w*\s*=\s*\[\n)(.*)(\n\s*\])\z/m)
2148
+ return unless template_match && destination_match
2149
+
2150
+ destination_elements = multiline_array_elements(destination_match[2])
2151
+ template_elements = multiline_array_elements(template_match[2])
2152
+ destination_keys = destination_elements.map do |element|
2153
+ normalize_array_element_key(element[:value])
2154
+ end.to_h { |key| [key, true] }
2155
+ appended = template_elements.reject { |element| destination_keys[normalize_array_element_key(element[:value])] }
2156
+ return destination_text if appended.empty?
2157
+
2158
+ insertion_prefix = destination_elements.last&.dig(:indent) || template_elements.first&.dig(:indent) || ' '
2159
+ body = append_multiline_array_elements(destination_match[2], appended, insertion_prefix)
2160
+ "#{destination_match[1]}#{body}#{destination_match[3]}"
2161
+ end
2162
+
2163
+ def merge_declaration_body_methods(template_text, destination_text)
2164
+ template_methods = direct_body_method_entries(template_text)
2165
+ destination_methods = direct_body_method_entries(destination_text)
2166
+ return destination_text if template_methods.empty?
2167
+
2168
+ destination_method_signatures = destination_methods.map do |entry|
2169
+ entry[:signature]
2170
+ end.to_h { |signature| [signature, true] }
2171
+ missing_methods = template_methods.reject { |entry| destination_method_signatures[entry[:signature]] }
2172
+ return destination_text if missing_methods.empty?
2173
+
2174
+ public_methods, visibility_methods = missing_methods.partition { |entry| entry[:visibility] == 'public' }
2175
+ merged_text = destination_text
2176
+ unless public_methods.empty?
2177
+ merged_text = insert_declaration_body_blocks(
2178
+ merged_text,
2179
+ public_methods.map { |entry| entry[:body_text] },
2180
+ before_visibility: !direct_visibility_section_present?(merged_text, 'public')
2181
+ )
2182
+ end
2183
+ visibility_methods.group_by { |entry| entry[:visibility] }.each do |visibility, entries|
2184
+ blocks = if direct_visibility_section_present?(merged_text, visibility)
2185
+ merged_text = insert_declaration_body_blocks(merged_text, entries.map do |entry|
2186
+ entry[:body_text]
2187
+ end, before_visibility: false)
2188
+ next
2189
+ else
2190
+ entries.map { |entry| entry[:text] }
2191
+ end
2192
+ merged_text = insert_declaration_body_blocks(merged_text, blocks)
2193
+ end
2194
+ merged_text
2195
+ end
2196
+
2197
+ def merge_nested_body_declarations(template_text, destination_text)
2198
+ template_entries = direct_body_declaration_entries(template_text)
2199
+ destination_entries = direct_body_declaration_entries(destination_text)
2200
+ return destination_text if template_entries.empty? || destination_entries.empty?
2201
+
2202
+ template_by_path = template_entries.to_h { |entry| [entry[:merge_key], entry] }
2203
+ output = destination_text.dup
2204
+ destination_entries.reverse_each do |destination_entry|
2205
+ template_entry = template_by_path[destination_entry[:merge_key]]
2206
+ next unless template_entry
2207
+
2208
+ output[destination_entry[:range]] = merge_ruby_declaration_entry(template_entry, destination_entry)[:text]
2209
+ end
2210
+
2211
+ destination_paths = destination_entries.map { |entry| entry[:merge_key] }.to_h { |path| [path, true] }
2212
+ missing_entries = template_entries.reject { |entry| destination_paths[entry[:merge_key]] }
2213
+ return output if missing_entries.empty?
2214
+
2215
+ insert_declaration_body_blocks(output, missing_entries.map { |entry| entry[:text] })
2216
+ end
2217
+
2218
+ def unsupported_feature_result(message)
2219
+ {
2220
+ ok: false,
2221
+ diagnostics: [{ severity: 'error', category: 'unsupported_feature', message: message }],
2222
+ policies: []
2223
+ }
2224
+ end
2225
+
2226
+ def normalize_method_move_policy(policy)
2227
+ normalized = policy.to_s.strip
2228
+ normalized = DEFAULT_METHOD_MOVE_POLICY if normalized.empty?
2229
+ return normalized if normalized == DEFAULT_METHOD_MOVE_POLICY
2230
+
2231
+ raise ArgumentError, "Unsupported Ruby method move policy #{policy.inspect}"
2232
+ end
2233
+
2234
+ private
2235
+
2236
+ def top_level_source_region_owners(lines)
2237
+ owners = []
2238
+ pending_comments = []
2239
+ index = 0
2240
+
2241
+ while index < lines.length
2242
+ line = lines[index]
2243
+ stripped = line.strip
2244
+
2245
+ if comment_line?(line)
2246
+ pending_comments << index
404
2247
  index += 1
405
2248
  next
406
2249
  end
407
- if REQUIRE_PATTERN.match?(line) || declaration_for_line(line)
408
- pending_comments = []
409
- index += 1
410
- next
2250
+
2251
+ if stripped.empty?
2252
+ pending_comments = []
2253
+ index += 1
2254
+ next
2255
+ end
2256
+
2257
+ if (match = REQUIRE_PATTERN.match(line))
2258
+ require_path = match[1]
2259
+ owners << {
2260
+ region_id: "require:#{require_path}",
2261
+ region_kind: 'owner',
2262
+ owner_kind: 'require',
2263
+ address: "/requires/#{require_path}",
2264
+ match_key: require_path,
2265
+ start_index: index,
2266
+ end_index: index,
2267
+ span: source_report_line_span(index, index),
2268
+ content: source_report_region_content(lines, index, index)
2269
+ }
2270
+ pending_comments = []
2271
+ index += 1
2272
+ next
2273
+ end
2274
+
2275
+ declaration = declaration_for_line(line)
2276
+ if declaration
2277
+ start_index = pending_comments.first || index
2278
+ finish_index = ruby_block_finish_index(lines, index)
2279
+ address = declaration[:kind] == 'def' ? "/methods/#{declaration[:name]}" : "/declarations/#{declaration[:name]}"
2280
+ owner = {
2281
+ region_id: "#{declaration[:kind] == 'def' ? 'method' : 'declaration'}:#{declaration[:name]}",
2282
+ region_kind: 'owner',
2283
+ owner_kind: declaration[:kind],
2284
+ address: address,
2285
+ match_key: declaration[:name],
2286
+ start_index: start_index,
2287
+ declaration_start_index: index,
2288
+ end_index: finish_index,
2289
+ span: source_report_line_span(start_index, finish_index),
2290
+ declaration_span: source_report_line_span(index, finish_index),
2291
+ content: source_report_region_content(lines, start_index, finish_index)
2292
+ }
2293
+ owner[:child_regions] = container_child_source_regions(lines, declaration, index, finish_index) if %w[class
2294
+ module].include?(declaration[:kind])
2295
+ attached_comments = source_attached_comment_regions_for_report(
2296
+ lines: lines,
2297
+ start_index: start_index,
2298
+ declaration_index: index
2299
+ )
2300
+ owner[:attached_comments] = attached_comments unless attached_comments.empty?
2301
+ owners << owner
2302
+ pending_comments = []
2303
+ index = finish_index + 1
2304
+ next
2305
+ end
2306
+
2307
+ pending_comments = []
2308
+ index += 1
2309
+ end
2310
+
2311
+ owners
2312
+ end
2313
+
2314
+ def container_child_source_regions(lines, declaration, declaration_index, finish_index)
2315
+ owners = []
2316
+ pending_comments = []
2317
+ index = declaration_index + 1
2318
+
2319
+ while index < finish_index
2320
+ line = lines[index]
2321
+ stripped = line.strip
2322
+
2323
+ if comment_line?(line)
2324
+ pending_comments << index
2325
+ index += 1
2326
+ next
2327
+ end
2328
+
2329
+ if stripped.empty?
2330
+ pending_comments = []
2331
+ index += 1
2332
+ next
2333
+ end
2334
+
2335
+ nested_declaration = declaration_for_line(line)
2336
+ if nested_declaration && %w[class module].include?(nested_declaration[:kind])
2337
+ pending_comments = []
2338
+ index = ruby_block_finish_index(lines, index) + 1
2339
+ next
2340
+ end
2341
+
2342
+ method = DEF_PATTERN.match(line)
2343
+ unless method
2344
+ pending_comments = []
2345
+ index += 1
2346
+ next
2347
+ end
2348
+
2349
+ start_index = pending_comments.first || index
2350
+ method_finish_index = ruby_block_finish_index(lines, index)
2351
+ method_name = method[2]
2352
+ owner = {
2353
+ region_id: "method:#{declaration[:name]}##{method_name}",
2354
+ region_kind: 'owner',
2355
+ owner_kind: 'method',
2356
+ address: "/declarations/#{declaration[:name]}/methods/#{method_name}",
2357
+ match_key: method_name,
2358
+ start_index: start_index,
2359
+ end_index: method_finish_index,
2360
+ span: source_report_line_span(start_index, method_finish_index),
2361
+ content: source_report_region_content(lines, start_index, method_finish_index)
2362
+ }
2363
+ owner[:declaration_span] = source_report_line_span(index, method_finish_index) if start_index != index
2364
+ attached_comments = source_attached_comment_regions_for_report(
2365
+ lines: lines,
2366
+ start_index: start_index,
2367
+ declaration_index: index
2368
+ )
2369
+ owner[:attached_comments] = attached_comments unless attached_comments.empty?
2370
+ owners << owner
2371
+ pending_comments = []
2372
+ index = method_finish_index + 1
2373
+ end
2374
+
2375
+ source_interleaved_regions_for_report(
2376
+ lines: lines,
2377
+ owners: owners,
2378
+ container_name: declaration[:name],
2379
+ container_start_index: declaration_index,
2380
+ container_end_index: finish_index
2381
+ )
2382
+ end
2383
+
2384
+ def compact_region(region)
2385
+ region.reject { |_key, value| value.nil? }
2386
+ end
2387
+
2388
+ RubyHashNode = Struct.new(:pairs, :inline, :trailing_comma, keyword_init: true)
2389
+ RubyHashPair = Struct.new(:key, :key_source, :delimiter, :value, keyword_init: true)
2390
+ RubyScalarNode = Struct.new(:source, keyword_init: true)
2391
+
2392
+ class RubyHashLiteralProjector
2393
+ def initialize(source)
2394
+ @source = source.to_s
2395
+ end
2396
+
2397
+ def call
2398
+ tree = TreeHaver.parser_for(:ruby, backend_type: :tree_sitter).parse(source)
2399
+ root = if tree.respond_to?(:parse_result)
2400
+ tree.parse_result.value
2401
+ else
2402
+ tree.root_node
2403
+ end
2404
+ hash_node = root_hash_node(root)
2405
+ raise ArgumentError, 'expected Ruby hash literal' unless hash_node?(hash_node)
2406
+
2407
+ project_hash_node(hash_node)
2408
+ end
2409
+
2410
+ private
2411
+
2412
+ attr_reader :source
2413
+
2414
+ def root_hash_node(root)
2415
+ return root if hash_node?(root)
2416
+ return root.statements&.body&.first if root.respond_to?(:statements)
2417
+
2418
+ child_nodes(root).find { |child| hash_node?(child) }
2419
+ end
2420
+
2421
+ def hash_node?(node)
2422
+ %w[hash hash_node].include?(node&.type.to_s)
2423
+ end
2424
+
2425
+ def project_hash_node(node)
2426
+ pairs = hash_pair_nodes(node).map do |assoc|
2427
+ key_node, value_node = hash_pair_key_value_nodes(assoc)
2428
+ key = project_hash_key(key_node, hash_pair_operator(assoc))
2429
+ RubyHashPair.new(
2430
+ key: key.fetch(:key),
2431
+ key_source: key.fetch(:key_source),
2432
+ delimiter: key.fetch(:delimiter),
2433
+ value: project_hash_value(value_node)
2434
+ )
2435
+ end
2436
+ RubyHashNode.new(
2437
+ pairs: pairs,
2438
+ inline: !node_source(node).include?("\n"),
2439
+ trailing_comma: trailing_comma?(node)
2440
+ )
2441
+ end
2442
+
2443
+ def project_hash_value(node)
2444
+ return project_hash_node(node) if hash_node?(node)
2445
+
2446
+ RubyScalarNode.new(source: node_source(node).rstrip)
2447
+ end
2448
+
2449
+ def project_hash_key(key_node, operator)
2450
+ delimiter = operator.to_s == '=>' ? '=>' : ':'
2451
+ if delimiter == ':'
2452
+ {
2453
+ key: hash_key_value(key_node),
2454
+ key_source: node_source(key_node).delete_suffix(':'),
2455
+ delimiter: delimiter
2456
+ }
2457
+ elsif %w[symbol_node simple_symbol].include?(key_node.type.to_s) && node_source(key_node).start_with?(':')
2458
+ {
2459
+ key: hash_key_value(key_node),
2460
+ key_source: node_source(key_node),
2461
+ delimiter: delimiter
2462
+ }
2463
+ elsif %w[string_node string].include?(key_node.type.to_s)
2464
+ {
2465
+ key: hash_key_value(key_node),
2466
+ key_source: node_source(key_node),
2467
+ delimiter: delimiter
2468
+ }
2469
+ else
2470
+ {
2471
+ key: hash_key_value(key_node),
2472
+ key_source: node_source(key_node),
2473
+ delimiter: delimiter
2474
+ }
2475
+ end
2476
+ end
2477
+
2478
+ def hash_pair_nodes(node)
2479
+ return Array(node.elements) if node.respond_to?(:elements)
2480
+
2481
+ child_nodes(node).select { |child| child.type.to_s == 'pair' }
2482
+ end
2483
+
2484
+ def hash_pair_key_value_nodes(pair)
2485
+ return [pair.key, pair.value] if pair.respond_to?(:key) && pair.respond_to?(:value)
2486
+
2487
+ named = child_nodes(pair).reject { |child| punctuation_node?(child) }
2488
+ [named.first, named.last]
2489
+ end
2490
+
2491
+ def hash_pair_operator(pair)
2492
+ return pair.operator if pair.respond_to?(:operator)
2493
+
2494
+ all_child_nodes(pair).find { |child| %w[: =>].include?(child.type.to_s) }&.type
2495
+ end
2496
+
2497
+ def hash_key_value(node)
2498
+ return node.unescaped.to_s if node.respond_to?(:unescaped)
2499
+
2500
+ text = node_source(node)
2501
+ return text.delete_prefix(':') if text.start_with?(':')
2502
+ return text[1...-1] if node.type.to_s == 'string' && text.match?(/\A(["']).*\1\z/m)
2503
+
2504
+ text
2505
+ end
2506
+
2507
+ def trailing_comma?(node)
2508
+ if node.respond_to?(:elements) && node.respond_to?(:closing_loc)
2509
+ return trailing_comma_between?(last_child_end: Array(node.elements).last.location.end_offset,
2510
+ closing_start: node.closing_loc.start_offset)
411
2511
  end
412
2512
 
413
- if line.match?(/\Abegin\b/)
414
- start_index = pending_comments.first || index
415
- finish_index = ruby_block_finish_index(lines, index)
416
- text = lines[start_index..finish_index].join("\n").strip
417
- signature = begin_block_signature(text)
418
- entries << { path: "/dsl/#{signature}", name: "begin", signature: signature, text: text }
419
- pending_comments = []
420
- index = finish_index + 1
421
- next
2513
+ last_pair = hash_pair_nodes(node).last
2514
+ closing = all_child_nodes(node).reverse.find { |child| child.type.to_s == '}' }
2515
+ return false unless last_pair && closing
2516
+
2517
+ trailing_comma_between?(last_child_end: node_end_offset(last_pair), closing_start: node_start_offset(closing))
2518
+ end
2519
+
2520
+ def trailing_comma_between?(last_child_end:, closing_start:)
2521
+ source.byteslice(last_child_end...closing_start).to_s.include?(',')
2522
+ end
2523
+
2524
+ def child_nodes(node)
2525
+ if node.respond_to?(:compact_child_nodes)
2526
+ node.compact_child_nodes
2527
+ elsif node.respond_to?(:named_children)
2528
+ node.named_children
2529
+ elsif node.respond_to?(:children)
2530
+ node.children
2531
+ else
2532
+ []
2533
+ end
2534
+ end
2535
+
2536
+ def all_child_nodes(node)
2537
+ if node.respond_to?(:compact_child_nodes)
2538
+ node.compact_child_nodes
2539
+ elsif node.respond_to?(:children)
2540
+ node.children
2541
+ else
2542
+ child_nodes(node)
422
2543
  end
2544
+ end
2545
+
2546
+ def punctuation_node?(node)
2547
+ %w[{ } : => ,].include?(node.type.to_s)
2548
+ end
2549
+
2550
+ def node_source(node)
2551
+ return node.slice.to_s if node.respond_to?(:slice)
2552
+ return node.text.to_s if node.respond_to?(:text)
2553
+
2554
+ source.byteslice(node_start_offset(node)...node_end_offset(node)).to_s
2555
+ end
2556
+
2557
+ def node_start_offset(node)
2558
+ return node.location.start_offset if node.respond_to?(:location)
2559
+ return node.start_byte if node.respond_to?(:start_byte)
423
2560
 
424
- match = DSL_CALL_PATTERN.match(line)
2561
+ raise ArgumentError, 'Ruby hash node does not expose a start offset'
2562
+ end
2563
+
2564
+ def node_end_offset(node)
2565
+ return node.location.end_offset if node.respond_to?(:location)
2566
+ return node.end_byte if node.respond_to?(:end_byte)
2567
+
2568
+ raise ArgumentError, 'Ruby hash node does not expose an end offset'
2569
+ end
2570
+ end
2571
+
2572
+ def constant_hash_blocks(text)
2573
+ lines = text.to_s.split("\n", -1)
2574
+ line_start_offsets = []
2575
+ offset = 0
2576
+ lines.each do |line|
2577
+ line_start_offsets << offset
2578
+ offset += line.length + 1
2579
+ end
2580
+
2581
+ blocks = []
2582
+ index = 0
2583
+ while index < lines.length
2584
+ line = lines[index]
2585
+ match = CONSTANT_HASH_ASSIGNMENT_PATTERN.match(line)
425
2586
  unless match
426
- pending_comments = []
427
2587
  index += 1
428
2588
  next
429
2589
  end
430
2590
 
431
- name = match[:name]
432
- if name == "desc" && next_code_line_is_task?(lines, index + 1)
433
- pending_comments << index
2591
+ start_line = index
2592
+ finish_line = hash_assignment_finish_line(lines, start_line)
2593
+ if finish_line
2594
+ block_source = lines[start_line..finish_line].join("\n")
2595
+ hash_offset = block_source.index('{')
2596
+ start_offset = line_start_offsets[start_line]
2597
+ finish_offset = line_start_offsets[finish_line] + lines[finish_line].length
2598
+ blocks << {
2599
+ constant: match[2],
2600
+ prefix: block_source[0...hash_offset],
2601
+ hash_source: block_source[hash_offset..],
2602
+ base_indent: match[1].length,
2603
+ range: (start_offset...finish_offset)
2604
+ }
2605
+ index = finish_line + 1
2606
+ else
434
2607
  index += 1
435
- next
436
2608
  end
437
-
438
- start_index = pending_comments.first || index
439
- finish_index = dsl_entry_finish_index(lines, index)
440
- text = lines[start_index..finish_index].join("\n").strip
441
- signature = dsl_entry_signature(name, line)
442
- entries << { path: "/dsl/#{signature}", name: name, signature: signature, text: text } if signature
443
- pending_comments = []
444
- index = finish_index + 1
445
2609
  end
446
-
447
- entries
2610
+ blocks
448
2611
  end
449
2612
 
450
- def merge_top_level_dsl_entries(destination_entries, template_entries)
451
- destination_by_signature = destination_entries.to_h { |entry| [entry[:signature], entry] }
452
- template_singletons = template_entries.select { |entry| dsl_singleton_entry?(entry) }
453
- template_singleton_signatures = template_singletons.map { |entry| entry[:signature] }.to_h { |signature| [signature, true] }
454
- result = []
455
- result.concat(template_singletons)
456
- result.concat(destination_entries.reject { |entry| template_singleton_signatures[entry[:signature]] })
457
- result.concat(
458
- template_entries.reject do |entry|
459
- dsl_singleton_entry?(entry) || destination_by_signature[entry[:signature]]
2613
+ def hash_assignment_finish_line(lines, start_line)
2614
+ depth = 0
2615
+ in_string = nil
2616
+ escape = false
2617
+ start_line.upto(lines.length - 1) do |line_index|
2618
+ lines[line_index].each_char do |char|
2619
+ if in_string
2620
+ if escape
2621
+ escape = false
2622
+ elsif char == '\\'
2623
+ escape = true
2624
+ elsif char == in_string
2625
+ in_string = nil
2626
+ end
2627
+ next
2628
+ end
2629
+
2630
+ if ['"', "'"].include?(char)
2631
+ in_string = char
2632
+ elsif char == '{'
2633
+ depth += 1
2634
+ elsif char == '}'
2635
+ depth -= 1
2636
+ return line_index if depth.zero?
2637
+ end
460
2638
  end
461
- )
462
- result
2639
+ end
2640
+ nil
463
2641
  end
464
2642
 
465
- def merge_ruby_requires(destination_requires, template_requires)
466
- destination_paths = destination_requires.to_h { |entry| [entry[:path], true] }
467
- destination_requires + template_requires.reject { |entry| destination_paths[entry[:path]] }
468
- end
2643
+ def direct_body_method_entries(text)
2644
+ lines = text.to_s.split("\n")
2645
+ return [] if lines.length < 3
469
2646
 
470
- def collect_ruby_declaration_entries(source)
471
- lines = normalize_source(source).split("\n")
472
2647
  entries = []
473
2648
  pending_comments = []
474
- index = 0
475
-
476
- while index < lines.length
2649
+ current_visibility = 'public'
2650
+ visibility_start_index = nil
2651
+ visibility_consumed = false
2652
+ index = 1
2653
+ while index < lines.length - 1
477
2654
  line = lines[index]
478
2655
  stripped = line.strip
479
-
480
2656
  if comment_line?(line)
481
2657
  pending_comments << index
482
2658
  index += 1
@@ -489,153 +2665,364 @@ module Ruby
489
2665
  next
490
2666
  end
491
2667
 
492
- if REQUIRE_PATTERN.match?(line)
2668
+ if %w[private protected public].include?(stripped)
2669
+ current_visibility = stripped
2670
+ visibility_start_index = index
2671
+ visibility_consumed = false
493
2672
  pending_comments = []
494
2673
  index += 1
495
2674
  next
496
2675
  end
497
2676
 
498
- declaration = declaration_for_line(line)
499
- unless declaration
2677
+ nested_declaration = declaration_for_line(line)
2678
+ if nested_declaration && %w[class module].include?(nested_declaration[:kind])
500
2679
  pending_comments = []
501
- index += 1
2680
+ index = ruby_block_finish_index(lines, index) + 1
502
2681
  next
503
2682
  end
504
2683
 
505
- start_index = pending_comments.first || index
506
- depth = 1
507
- cursor = index + 1
508
- while cursor < lines.length
509
- candidate = lines[cursor].strip
510
- depth += 1 if declaration_for_line(candidate)
511
- if candidate == "end"
512
- depth -= 1
513
- if depth.zero?
514
- cursor += 1
515
- break
516
- end
517
- end
518
- cursor += 1
2684
+ match = DEF_PATTERN.match(line)
2685
+ unless match
2686
+ pending_comments = []
2687
+ index += 1
2688
+ next
519
2689
  end
520
2690
 
2691
+ start_index = pending_comments.first || visibility_section_start_index(visibility_start_index,
2692
+ visibility_consumed) || index
2693
+ finish_index = ruby_block_finish_index(lines, index)
521
2694
  entries << {
522
- path: "/declarations/#{declaration[:name]}",
523
- text: lines[start_index...cursor].join("\n").strip
2695
+ name: match[2],
2696
+ signature: SignatureSupport.textual_method_signature(match[1], match[2]),
2697
+ visibility: current_visibility,
2698
+ text: lines[start_index..finish_index].join("\n").rstrip,
2699
+ body_text: lines[(pending_comments.first || index)..finish_index].join("\n").rstrip
524
2700
  }
525
2701
  pending_comments = []
526
- index = cursor
2702
+ visibility_consumed = true
2703
+ index = finish_index + 1
527
2704
  end
528
-
529
2705
  entries
530
2706
  end
531
2707
 
532
- def unsupported_feature_result(message)
533
- {
534
- ok: false,
535
- diagnostics: [{ severity: "error", category: "unsupported_feature", message: message }],
536
- policies: []
537
- }
2708
+ def direct_body_constant_entries(text)
2709
+ lines = text.to_s.split("\n")
2710
+ return [] if lines.length < 3
2711
+
2712
+ line_start_offsets = []
2713
+ offset = 0
2714
+ lines.each do |line|
2715
+ line_start_offsets << offset
2716
+ offset += line.length + 1
2717
+ end
2718
+
2719
+ entries = []
2720
+ index = 1
2721
+ while index < lines.length - 1
2722
+ stripped = lines[index].strip
2723
+ if stripped.empty? || comment_line?(lines[index])
2724
+ index += 1
2725
+ next
2726
+ end
2727
+
2728
+ nested_declaration = declaration_for_line(lines[index])
2729
+ if nested_declaration && %w[class module].include?(nested_declaration[:kind])
2730
+ index = ruby_block_finish_index(lines, index) + 1
2731
+ next
2732
+ end
2733
+
2734
+ match = CONSTANT_ASSIGNMENT_PATTERN.match(lines[index])
2735
+ unless match
2736
+ index += 1
2737
+ next
2738
+ end
2739
+
2740
+ finish_index = constant_assignment_finish_index(lines, index)
2741
+ entries << {
2742
+ name: match[2],
2743
+ text: lines[index..finish_index].join("\n").rstrip,
2744
+ range: (line_start_offsets[index]...(line_start_offsets[finish_index] + lines[finish_index].length))
2745
+ }
2746
+ index = finish_index + 1
2747
+ end
2748
+ entries
538
2749
  end
539
2750
 
540
- private
2751
+ def split_ruby_array_elements(source)
2752
+ elements = []
2753
+ start_index = 0
2754
+ string_quote = nil
2755
+ escape = false
2756
+ source.each_char.with_index do |char, index|
2757
+ if string_quote
2758
+ if escape
2759
+ escape = false
2760
+ elsif char == '\\'
2761
+ escape = true
2762
+ elsif char == string_quote
2763
+ string_quote = nil
2764
+ end
2765
+ next
2766
+ end
541
2767
 
542
- def comment_line?(line)
543
- line.lstrip.start_with?("#")
2768
+ if ['"', "'"].include?(char)
2769
+ string_quote = char
2770
+ elsif char == ','
2771
+ elements << source[start_index...index].strip
2772
+ start_index = index + 1
2773
+ end
2774
+ end
2775
+ elements << source[start_index..].to_s.strip
2776
+ elements.reject(&:empty?)
544
2777
  end
545
2778
 
546
- def declaration_for_line(line)
547
- if (match = CLASS_PATTERN.match(line))
548
- { kind: "class", name: match[1] }
549
- elsif (match = MODULE_PATTERN.match(line))
550
- { kind: "module", name: match[1] }
551
- elsif (match = DEF_PATTERN.match(line))
552
- { kind: "def", name: match[1] }
2779
+ def normalize_array_element_key(element)
2780
+ element.to_s.strip
2781
+ end
2782
+
2783
+ def multiline_array_elements(source)
2784
+ source.to_s.lines.filter_map do |line|
2785
+ stripped = line.strip
2786
+ next if stripped.empty? || stripped.start_with?('#')
2787
+
2788
+ {
2789
+ indent: line[/\A\s*/],
2790
+ value: stripped.sub(/,\z/, '')
2791
+ }
553
2792
  end
554
2793
  end
555
2794
 
556
- def next_code_line_is_task?(lines, start_index)
557
- lines[start_index..].to_a.each do |line|
558
- next if line.strip.empty? || comment_line?(line)
2795
+ def append_multiline_array_elements(destination_body, appended, insertion_prefix)
2796
+ body_lines = destination_body.to_s.lines.map(&:chomp)
2797
+ element_indexes = body_lines.each_index.select do |index|
2798
+ stripped = body_lines[index].strip
2799
+ !stripped.empty? && !stripped.start_with?('#')
2800
+ end
2801
+ trailing_comma = element_indexes.empty? || body_lines[element_indexes.last].strip.end_with?(',')
559
2802
 
560
- match = DSL_CALL_PATTERN.match(line)
561
- return match && match[:name] == "task"
2803
+ if trailing_comma
2804
+ insertion_lines = appended.map { |element| "#{insertion_prefix}#{element[:value]}," }
2805
+ return "#{destination_body.rstrip}\n#{insertion_lines.join("\n")}"
562
2806
  end
563
- false
2807
+
2808
+ body_lines[element_indexes.last] = "#{body_lines[element_indexes.last]},"
2809
+ insertion_lines = appended.each_with_index.map do |element, index|
2810
+ suffix = index == appended.length - 1 ? '' : ','
2811
+ "#{insertion_prefix}#{element[:value]}#{suffix}"
2812
+ end
2813
+ "#{body_lines.join("\n").rstrip}\n#{insertion_lines.join("\n")}"
564
2814
  end
565
2815
 
566
- def dsl_entry_finish_index(lines, start_index)
567
- return start_index unless lines[start_index].match?(/\bdo\b/)
2816
+ def constant_assignment_finish_index(lines, index)
2817
+ return hash_assignment_finish_line(lines, index) || index if lines[index].include?('{')
2818
+ return array_assignment_finish_line(lines, index) || index if lines[index].include?('[')
568
2819
 
569
- ruby_block_finish_index(lines, start_index)
2820
+ index
570
2821
  end
571
2822
 
572
- def ruby_block_finish_index(lines, start_index)
2823
+ def array_assignment_finish_line(lines, start_line)
573
2824
  depth = 0
574
- cursor = start_index
575
- while cursor < lines.length
576
- stripped = lines[cursor].strip
577
- depth += stripped.scan(/\bdo\b/).length
578
- depth += 1 if declaration_for_line(stripped) || stripped.match?(/\A(begin|if|unless|case|while|until|for)\b/)
579
- depth -= 1 if stripped == "end"
580
- return cursor if depth <= 0 && cursor > start_index
2825
+ in_string = nil
2826
+ escape = false
2827
+ start_line.upto(lines.length - 1) do |line_index|
2828
+ lines[line_index].each_char do |char|
2829
+ if in_string
2830
+ if escape
2831
+ escape = false
2832
+ elsif char == '\\'
2833
+ escape = true
2834
+ elsif char == in_string
2835
+ in_string = nil
2836
+ end
2837
+ next
2838
+ end
581
2839
 
582
- cursor += 1
2840
+ if ['"', "'"].include?(char)
2841
+ in_string = char
2842
+ elsif char == '['
2843
+ depth += 1
2844
+ elsif char == ']'
2845
+ depth -= 1
2846
+ return line_index if depth.zero?
2847
+ end
2848
+ end
583
2849
  end
584
- lines.length - 1
2850
+ nil
2851
+ end
2852
+
2853
+ def visibility_section_start_index(index, consumed)
2854
+ return if consumed
2855
+
2856
+ index
2857
+ end
2858
+
2859
+ def direct_body_declaration_entries(text)
2860
+ lines = text.to_s.split("\n")
2861
+ return [] if lines.length < 3
2862
+
2863
+ line_start_offsets = []
2864
+ offset = 0
2865
+ lines.each do |line|
2866
+ line_start_offsets << offset
2867
+ offset += line.length + 1
2868
+ end
2869
+
2870
+ entries = []
2871
+ index = 1
2872
+ while index < lines.length - 1
2873
+ declaration = declaration_for_line(lines[index].strip)
2874
+ unless declaration && %w[class module].include?(declaration[:kind])
2875
+ index += 1
2876
+ next
2877
+ end
2878
+
2879
+ finish_index = ruby_block_finish_index(lines, index)
2880
+ start_offset = line_start_offsets[index]
2881
+ finish_offset = line_start_offsets[finish_index] + lines[finish_index].length
2882
+ entries << {
2883
+ path: "/declarations/#{declaration[:name]}",
2884
+ name: declaration[:name],
2885
+ kind: declaration[:kind],
2886
+ merge_key: "#{declaration[:kind]}:#{declaration[:name]}",
2887
+ text: lines[index..finish_index].join("\n").rstrip,
2888
+ range: (start_offset...finish_offset)
2889
+ }
2890
+ index = finish_index + 1
2891
+ end
2892
+ entries
2893
+ end
2894
+
2895
+ def declaration_closing_end_index(lines)
2896
+ depth = 0
2897
+ lines.each_with_index do |line, index|
2898
+ stripped = line.strip
2899
+ depth += 1 if declaration_for_line(stripped)
2900
+ depth -= 1 if stripped == 'end'
2901
+ return index if depth.zero? && index.positive?
2902
+ end
2903
+ nil
2904
+ end
2905
+
2906
+ def insert_declaration_body_blocks(destination_text, blocks, before_visibility: true, placement: :before_closing)
2907
+ lines = destination_text.to_s.split("\n")
2908
+ closing_index = declaration_closing_end_index(lines)
2909
+ return destination_text unless closing_index
2910
+
2911
+ insertion_index = if placement == :after_opening
2912
+ 1
2913
+ elsif before_visibility
2914
+ direct_visibility_section_index(lines, closing_index) || closing_index
2915
+ else
2916
+ closing_index
2917
+ end
2918
+ insertion = []
2919
+ insertion << '' unless insertion_index == 1 || lines[insertion_index - 1].to_s.strip.empty?
2920
+ insertion.concat(blocks.join("\n\n").split("\n"))
2921
+ insertion << '' if insertion_index != closing_index && !lines[insertion_index].to_s.strip.empty?
2922
+ lines.insert(insertion_index, *insertion)
2923
+ "#{lines.join("\n").sub(/\n+\z/, '')}\n".chomp
2924
+ end
2925
+
2926
+ def direct_visibility_section_present?(text, visibility)
2927
+ lines = text.to_s.split("\n")
2928
+ closing_index = declaration_closing_end_index(lines)
2929
+ return false unless closing_index
2930
+
2931
+ find_direct_visibility_section_index(lines, closing_index, visibility: visibility)
2932
+ end
2933
+
2934
+ def direct_visibility_section_index(lines, closing_index)
2935
+ find_direct_visibility_section_index(lines, closing_index, visibility: nil)
585
2936
  end
586
2937
 
587
- def begin_block_signature(text)
588
- require_path = text[/^\s*require(?:_relative)?\s+["']([^"']+)["']/, 1]
589
- return "begin:require:#{require_path}" if require_path
2938
+ def find_direct_visibility_section_index(lines, closing_index, visibility:)
2939
+ depth = 1
2940
+ 1.upto(closing_index - 1) do |index|
2941
+ stripped = lines[index].strip
2942
+ visibility_match = visibility ? stripped == visibility : %w[private protected].include?(stripped)
2943
+ return index if depth == 1 && visibility_match
590
2944
 
591
- "begin:#{text.lines.first.to_s.strip}"
2945
+ depth += 1 if declaration_for_line(stripped)
2946
+ depth -= 1 if stripped == 'end'
2947
+ end
2948
+ nil
592
2949
  end
593
2950
 
594
- def dsl_entry_signature(name, line)
595
- case name
596
- when "source", "gemspec"
597
- name
598
- when "git_source", "gem", "eval_gemfile", "platform", "group", "task"
599
- first_argument = line[/\b#{Regexp.escape(name)}\s*(?:\(|\s)\s*["']([^"']+)["']/, 1] ||
600
- line[/\b#{Regexp.escape(name)}\s*(?:\(|\s)\s*:([a-zA-Z_]\w*[!?=]?)/, 1]
601
- first_argument ? "#{name}:#{normalize_dsl_argument(name, first_argument)}" : "#{name}:#{line.strip}"
602
- when "desc"
603
- "desc:#{line.strip}"
2951
+ def merge_ruby_hash_literals(template, destination)
2952
+ destination_by_key = destination.pairs.to_h { |pair| [pair.key, pair] }
2953
+ merged_pairs = template.pairs.map do |template_pair|
2954
+ destination_pair = destination_by_key[template_pair.key]
2955
+ if destination_pair.nil?
2956
+ template_pair
2957
+ elsif template_pair.value.is_a?(RubyHashNode) && destination_pair.value.is_a?(RubyHashNode)
2958
+ RubyHashPair.new(
2959
+ key: template_pair.key,
2960
+ key_source: destination_pair.key_source,
2961
+ delimiter: destination_pair.delimiter,
2962
+ value: merge_ruby_hash_literals(template_pair.value, destination_pair.value)
2963
+ )
2964
+ else
2965
+ destination_pair
2966
+ end
604
2967
  end
2968
+ template_keys = template.pairs.map(&:key).to_h { |key| [key, true] }
2969
+ merged_pairs.concat(destination.pairs.reject { |pair| template_keys[pair.key] })
2970
+ RubyHashNode.new(pairs: merged_pairs, inline: destination.inline, trailing_comma: destination.trailing_comma)
605
2971
  end
606
2972
 
607
- def normalize_dsl_argument(name, argument)
608
- return argument.gsub(%r{/r\d+/}, "/") if name == "eval_gemfile"
2973
+ def render_ruby_hash_literal(node, base_indent)
2974
+ return node.source unless node.is_a?(RubyHashNode)
2975
+ return render_inline_ruby_hash_literal(node) if node.inline
609
2976
 
610
- argument
2977
+ child_indent = base_indent + 2
2978
+ lines = node.pairs.each_with_index.map do |pair, index|
2979
+ suffix = index == node.pairs.length - 1 && !node.trailing_comma ? '' : ','
2980
+ "#{' ' * child_indent}#{render_ruby_hash_key(pair)} #{render_ruby_hash_literal(pair.value,
2981
+ child_indent)}#{suffix}"
2982
+ end
2983
+ "{\n#{lines.join("\n")}\n#{' ' * base_indent}}"
611
2984
  end
612
2985
 
613
- def dsl_singleton_entry?(entry)
614
- %w[source gemspec].include?(entry[:name])
2986
+ def render_inline_ruby_hash_literal(node)
2987
+ inner = node.pairs.map do |pair|
2988
+ "#{render_ruby_hash_key(pair)} #{render_ruby_hash_literal(pair.value, 0)}"
2989
+ end.join(', ')
2990
+ inner = "#{inner}," if node.trailing_comma && !inner.empty?
2991
+ "{#{inner}}"
615
2992
  end
616
2993
 
617
- def normalize_rakefile_default_task_scaffold(content)
618
- lines = normalize_source(content).split("\n")
619
- desc_index = lines.find_index { |line| line.strip == RAKEFILE_DEFAULT_TASK_DESC }
620
- return content unless desc_index
2994
+ def render_ruby_hash_key(pair)
2995
+ delimiter = pair.delimiter == '=>' ? '=>' : ':'
2996
+ delimiter == '=>' ? "#{pair.key_source} =>" : "#{pair.key_source}:"
2997
+ end
621
2998
 
622
- comment_index = preceding_code_line_index(lines, desc_index - 1)
623
- return content unless comment_index && lines[comment_index].strip == RAKEFILE_DEFAULT_TASK_COMMENT
2999
+ def comment_line?(line)
3000
+ line.lstrip.start_with?('#')
3001
+ end
624
3002
 
625
- next_code_index = next_code_line_index(lines, desc_index + 1)
626
- return content if next_code_index && lines[next_code_index].match?(/\Atask\s+:default\b/)
3003
+ def declaration_for_line(line)
3004
+ if (match = CLASS_PATTERN.match(line))
3005
+ { kind: 'class', name: match[1] }
3006
+ elsif (match = MODULE_PATTERN.match(line))
3007
+ { kind: 'module', name: match[1] }
3008
+ elsif (match = DEF_PATTERN.match(line))
3009
+ { kind: 'def', name: match[2], signature: SignatureSupport.textual_method_signature(match[1], match[2]) }
3010
+ end
3011
+ end
627
3012
 
628
- task_index = lines.each_index.find { |index| lines[index].match?(/\Atask\s+:default\b/) }
629
- return content unless task_index
3013
+ def ruby_block_finish_index(lines, start_index)
3014
+ depth = 0
3015
+ cursor = start_index
3016
+ while cursor < lines.length
3017
+ stripped = lines[cursor].strip
3018
+ depth += stripped.scan(/\bdo\b/).length
3019
+ depth += 1 if declaration_for_line(stripped) || stripped.match?(/\A(begin|if|unless|case|while|until|for)\b/)
3020
+ depth -= 1 if stripped == 'end'
3021
+ return cursor if depth <= 0 && cursor > start_index
630
3022
 
631
- finish_index = dsl_entry_finish_index(lines, task_index)
632
- task_block = lines[task_index..finish_index]
633
- lines[task_index..finish_index] = []
634
- insertion_index = lines.find_index { |line| line.strip == RAKEFILE_DEFAULT_TASK_DESC } + 1
635
- insertion = task_block.dup
636
- insertion << "" unless lines[insertion_index].to_s.strip.empty?
637
- lines.insert(insertion_index, *insertion)
638
- "#{lines.join("\n").sub(/\n+\z/, "")}\n"
3023
+ cursor += 1
3024
+ end
3025
+ lines.length - 1
639
3026
  end
640
3027
 
641
3028
  def preceding_code_line_index(lines, start_index)
@@ -663,14 +3050,14 @@ module Ruby
663
3050
  start_line = filtered_entries.first[:line]
664
3051
  end_line = filtered_entries.last[:line]
665
3052
  doc_surface = Ast::Merge.discovered_surface(
666
- surface_kind: "ruby_doc_comment",
667
- declared_language: "yard",
668
- effective_language: "yard",
3053
+ surface_kind: 'ruby_doc_comment',
3054
+ declared_language: 'yard',
3055
+ effective_language: 'yard',
669
3056
  address: "document[0] > ruby_doc_comment[#{owner_name}]",
670
- parent_address: "document[0]",
671
- owner: Ast::Merge.surface_owner_ref(kind: "owned_region", address: "/declarations/#{owner_name}"),
3057
+ parent_address: 'document[0]',
3058
+ owner: Ast::Merge.surface_owner_ref(kind: 'owned_region', address: "/declarations/#{owner_name}"),
672
3059
  span: Ast::Merge.surface_span(start_line: start_line, end_line: end_line),
673
- reconstruction_strategy: "rewrite_with_prefix_preservation",
3060
+ reconstruction_strategy: 'rewrite_with_prefix_preservation',
674
3061
  metadata: {
675
3062
  owner_signature: owner_name,
676
3063
  comment_prefix: comment_prefix_for(filtered_entries.first[:raw]),
@@ -683,33 +3070,22 @@ module Ruby
683
3070
 
684
3071
  def example_surfaces_for(surface)
685
3072
  entries = Array(surface.dig(:metadata, :entries))
686
- normalized = entries.map { |entry| normalize_comment_content(entry[:raw]) }
687
-
688
- normalized.each_with_index.filter_map do |content, tag_index|
689
- match = EXAMPLE_TAG.match(content)
690
- next unless match
691
-
692
- body_start = tag_index + 1
693
- body_end = next_tag_index(normalized, body_start) || normalized.length
694
- next if body_start >= body_end
695
-
696
- body_entries = entries[body_start...body_end]
697
- next if body_entries.nil? || body_entries.empty?
698
-
699
- declared_language = declared_example_language(match[:rest]) || "ruby"
3073
+ DocCommentSupport.example_blocks(entries).map do |block|
3074
+ body_entries = block.fetch(:body_entries)
3075
+ declared_language = block.fetch(:declared_language) || 'ruby'
700
3076
  Ast::Merge.discovered_surface(
701
- surface_kind: "yard_example_block",
3077
+ surface_kind: 'yard_example_block',
702
3078
  declared_language: declared_language,
703
3079
  effective_language: declared_language,
704
- address: "#{surface[:address]} > yard_example[#{tag_index}]",
3080
+ address: "#{surface[:address]} > yard_example[#{block.fetch(:tag_index)}]",
705
3081
  parent_address: surface[:address],
706
- owner: Ast::Merge.surface_owner_ref(kind: "owned_region", address: surface[:address]),
3082
+ owner: Ast::Merge.surface_owner_ref(kind: 'owned_region', address: surface[:address]),
707
3083
  span: Ast::Merge.surface_span(start_line: body_entries.first[:line], end_line: body_entries.last[:line]),
708
- reconstruction_strategy: "rewrite_with_prefix_preservation",
3084
+ reconstruction_strategy: 'rewrite_with_prefix_preservation',
709
3085
  metadata: {
710
- tag_kind: "example",
711
- tag_index: tag_index,
712
- tag_text: normalized[tag_index],
3086
+ tag_kind: 'example',
3087
+ tag_index: block.fetch(:tag_index),
3088
+ tag_text: block.fetch(:tag_text),
713
3089
  comment_prefix: surface.dig(:metadata, :comment_prefix)
714
3090
  }
715
3091
  )
@@ -717,11 +3093,7 @@ module Ruby
717
3093
  end
718
3094
 
719
3095
  def next_tag_index(normalized_lines, start_index)
720
- normalized_lines.each_with_index do |content, index|
721
- next if index < start_index
722
- return index if TAG_PREFIX.match?(content)
723
- end
724
- nil
3096
+ DocCommentSupport.next_tag_index(normalized_lines, start_index)
725
3097
  end
726
3098
 
727
3099
  def normalize_source(source)
@@ -729,37 +3101,30 @@ module Ruby
729
3101
  end
730
3102
 
731
3103
  def normalize_comment_content(raw)
732
- raw.to_s.sub(/\A\s*#\s?/, "").strip
3104
+ DocCommentSupport.normalize_comment_content(raw)
733
3105
  end
734
3106
 
735
3107
  def doc_comment_content?(raw)
736
- content = normalize_comment_content(raw)
737
- return false if content.empty?
738
- return false if DIRECTIVE_LINE.match?(content)
739
- return false if MAGIC_COMMENT_PREFIXES.any? { |prefix| content.start_with?("#{prefix}:") }
740
-
741
- true
3108
+ DocCommentSupport.doc_comment_content?(raw)
742
3109
  end
743
3110
 
744
3111
  def comment_prefix_for(raw)
745
- raw.to_s[/\A\s*#\s*/] || "# "
3112
+ DocCommentSupport.comment_prefix_for(raw)
746
3113
  end
747
3114
 
748
3115
  def declared_example_language(rest)
749
- match = rest.to_s.strip.match(/\A\[(?<language>[^\]]+)\]/)
750
- language = match && match[:language]
751
- return if language.nil? || language.empty?
752
-
753
- language.downcase.tr("-", "_")
3116
+ DocCommentSupport.declared_example_language(rest)
754
3117
  end
755
3118
 
756
3119
  module_function(
757
3120
  :ruby_feature_profile,
758
3121
  :available_ruby_backends,
3122
+ :ruby_tslp_capability_profile,
759
3123
  :ruby_backend_feature_profile,
760
3124
  :ruby_plan_context,
761
3125
  :parse_ruby,
762
3126
  :match_ruby_owners,
3127
+ :ruby_method_move_detection,
763
3128
  :merge_ruby,
764
3129
  :ruby_discovered_surfaces,
765
3130
  :ruby_delegated_child_operations,
@@ -771,9 +3136,54 @@ module Ruby
771
3136
  :merge_ruby_with_reviewed_nested_outputs_from_review_state_envelope,
772
3137
  :merge_ruby_with_nested_outputs,
773
3138
  :analyze_ruby_document,
774
- :collect_ruby_require_entries,
775
3139
  :collect_ruby_declaration_entries,
776
3140
  :unsupported_feature_result
777
3141
  )
778
3142
  end
779
3143
  end
3144
+
3145
+ Ruby::Merge.register_backend!
3146
+
3147
+ TreeHaver::BackendRegistry.register_tag(
3148
+ :tslp_ruby_import_records,
3149
+ category: :capability,
3150
+ backend_name: :tslp_ruby_import_records
3151
+ ) do
3152
+ result = Ruby::Merge.parse_ruby("require \"json\"\n", 'ruby')
3153
+ result[:ok] && Array(result.dig(:analysis, :owners)).any? do |owner|
3154
+ owner[:owner_kind] == 'require' && owner[:match_key] == 'json'
3155
+ end
3156
+ end
3157
+
3158
+ TreeHaver::BackendRegistry.register_tag(
3159
+ :tslp_ruby_top_level_call_records,
3160
+ category: :capability,
3161
+ backend_name: :tslp_ruby_top_level_call_records
3162
+ ) do
3163
+ template = <<~RUBY
3164
+ source "https://gem.coop"
3165
+ gemspec
3166
+ eval_gemfile "gemfiles/modular/style.gemfile"
3167
+ gem "rake"
3168
+ RUBY
3169
+ destination = <<~RUBY
3170
+ source "https://rubygems.org"
3171
+ gem "rspec"
3172
+ eval_gemfile "gemfiles/modular/style.gemfile"
3173
+ RUBY
3174
+ result = Ruby::Merge.merge_ruby(template, destination, 'ruby')
3175
+ result[:ok]
3176
+ end
3177
+
3178
+ TreeHaver::BackendRegistry.register_tag(
3179
+ :tslp_ruby_namespace_form_equivalence,
3180
+ category: :capability,
3181
+ backend_name: :tslp_ruby_namespace_form_equivalence
3182
+ ) do
3183
+ template = Ruby::Merge.parse_ruby("module Admin\n class User\n end\nend\n", 'ruby')
3184
+ destination = Ruby::Merge.parse_ruby("class Admin::User\nend\n", 'ruby')
3185
+ template[:ok] && destination[:ok] && Ruby::Merge.ruby_namespace_form_conflicts(
3186
+ Ruby::Merge.send(:ruby_tslp_merge_context, template.fetch(:analysis), role: 'template').fetch(:declarations),
3187
+ Ruby::Merge.send(:ruby_tslp_merge_context, destination.fetch(:analysis), role: 'destination').fetch(:declarations)
3188
+ ).empty?
3189
+ end