ruby-merge 7.0.0 → 7.1.3

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