rigortype 0.1.1 → 0.1.2

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/rigor/cli.rb CHANGED
@@ -22,7 +22,9 @@ module Rigor
22
22
  "check" => :run_check,
23
23
  "init" => :run_init,
24
24
  "type-of" => :run_type_of,
25
- "type-scan" => :run_type_scan
25
+ "type-scan" => :run_type_scan,
26
+ "explain" => :run_explain,
27
+ "diff" => :run_diff
26
28
  }.freeze
27
29
 
28
30
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -207,6 +209,7 @@ module Rigor
207
209
  # most likely to want to edit.
208
210
  def init_template
209
211
  <<~YAML
212
+ # yaml-language-server: $schema=https://github.com/zenwerk/rigor/raw/master/schemas/rigor-config.schema.json
210
213
  # Rigor configuration. See docs/CURRENT_WORK.md for the
211
214
  # full set of features the analyzer ships in this preview.
212
215
  #
@@ -264,6 +267,18 @@ module Rigor
264
267
  TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
265
268
  end
266
269
 
270
+ def run_explain
271
+ require_relative "cli/explain_command"
272
+
273
+ ExplainCommand.new(argv: @argv, out: @out, err: @err).run
274
+ end
275
+
276
+ def run_diff
277
+ require_relative "cli/diff_command"
278
+
279
+ DiffCommand.new(argv: @argv, out: @out, err: @err).run
280
+ end
281
+
267
282
  def write_result(result, format)
268
283
  case format
269
284
  when "json"
@@ -301,6 +316,8 @@ module Rigor
301
316
  init Create a starter .rigor.yml
302
317
  type-of Print the inferred type at FILE:LINE:COL
303
318
  type-scan Report Scope#type_of coverage across PATHs
319
+ explain Print the description of one or all CheckRules
320
+ diff Compare current diagnostics to a saved baseline JSON
304
321
  version Print the Rigor version
305
322
  help Print this help
306
323
  HELP
@@ -43,9 +43,14 @@ module Rigor
43
43
  "call.argument-type-mismatch" => :warning,
44
44
  "call.possible-nil-receiver" => :warning,
45
45
  "flow.always-raises" => :warning,
46
+ "flow.unreachable-branch" => :info,
47
+ "flow.dead-assignment" => :info,
48
+ "flow.always-truthy-condition" => :info,
46
49
  "assert.type-mismatch" => :error,
47
50
  "dump.type" => :info,
48
- "def.return-type-mismatch" => :warning
51
+ "def.return-type-mismatch" => :warning,
52
+ "def.method-visibility-mismatch" => :warning,
53
+ "def.ivar-write-mismatch" => :warning
49
54
  }.freeze,
50
55
  balanced: {
51
56
  "call.undefined-method" => :error,
@@ -53,9 +58,14 @@ module Rigor
53
58
  "call.argument-type-mismatch" => :error,
54
59
  "call.possible-nil-receiver" => :error,
55
60
  "flow.always-raises" => :error,
61
+ "flow.unreachable-branch" => :warning,
62
+ "flow.dead-assignment" => :warning,
63
+ "flow.always-truthy-condition" => :warning,
56
64
  "assert.type-mismatch" => :error,
57
65
  "dump.type" => :info,
58
- "def.return-type-mismatch" => :warning
66
+ "def.return-type-mismatch" => :warning,
67
+ "def.method-visibility-mismatch" => :error,
68
+ "def.ivar-write-mismatch" => :warning
59
69
  }.freeze,
60
70
  strict: {
61
71
  "call.undefined-method" => :error,
@@ -63,9 +73,14 @@ module Rigor
63
73
  "call.argument-type-mismatch" => :error,
64
74
  "call.possible-nil-receiver" => :error,
65
75
  "flow.always-raises" => :error,
76
+ "flow.unreachable-branch" => :error,
77
+ "flow.dead-assignment" => :error,
78
+ "flow.always-truthy-condition" => :error,
66
79
  "assert.type-mismatch" => :error,
67
80
  "dump.type" => :error,
68
- "def.return-type-mismatch" => :error
81
+ "def.return-type-mismatch" => :error,
82
+ "def.method-visibility-mismatch" => :error,
83
+ "def.ivar-write-mismatch" => :error
69
84
  }.freeze
70
85
  }.freeze
71
86
 
@@ -54,7 +54,8 @@ module Rigor
54
54
  },
55
55
  "plugins_io" => {
56
56
  "network" => "disabled",
57
- "allowed_paths" => []
57
+ "allowed_paths" => [],
58
+ "allowed_url_hosts" => []
58
59
  },
59
60
  "severity_profile" => "balanced",
60
61
  "severity_overrides" => {}
@@ -70,6 +71,7 @@ module Rigor
70
71
  attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
71
72
  :libraries, :signature_paths, :fold_platform_specific_paths,
72
73
  :plugins_io_network, :plugins_io_allowed_paths,
74
+ :plugins_io_allowed_url_hosts,
73
75
  :severity_profile, :severity_overrides
74
76
 
75
77
  # Loads a configuration file.
@@ -182,7 +184,8 @@ module Rigor
182
184
  end
183
185
  private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
184
186
 
185
- def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
187
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
188
+ def initialize(data = DEFAULTS)
186
189
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
187
190
  plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
188
191
 
@@ -203,6 +206,7 @@ module Rigor
203
206
  @cache_path = cache.fetch("path").to_s
204
207
  @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
205
208
  @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
209
+ @plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
206
210
  @severity_profile = coerce_severity_profile(
207
211
  data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
208
212
  )
@@ -210,6 +214,7 @@ module Rigor
210
214
  data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
211
215
  )
212
216
  end
217
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
213
218
 
214
219
  def to_h
215
220
  {
@@ -226,7 +231,8 @@ module Rigor
226
231
  },
227
232
  "plugins_io" => {
228
233
  "network" => plugins_io_network.to_s,
229
- "allowed_paths" => plugins_io_allowed_paths
234
+ "allowed_paths" => plugins_io_allowed_paths,
235
+ "allowed_url_hosts" => plugins_io_allowed_url_hosts
230
236
  },
231
237
  "severity_profile" => severity_profile.to_s,
232
238
  "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
@@ -284,7 +290,7 @@ module Rigor
284
290
  # `Configuration` does not require the plugin namespace at
285
291
  # load time (Configuration is loaded before Plugin in
286
292
  # `lib/rigor.rb`); the two stay in lockstep via spec.
287
- VALID_NETWORK_POLICIES = %i[disabled].freeze
293
+ VALID_NETWORK_POLICIES = %i[disabled allowlist].freeze
288
294
  private_constant :VALID_NETWORK_POLICIES
289
295
 
290
296
  def coerce_network_policy(value)
@@ -8,24 +8,40 @@ module Rigor
8
8
  module Inference
9
9
  module MethodDispatcher
10
10
  # Picks the RBS overload that should answer a call given the
11
- # caller's actual argument types. Slice 4 phase 2c shape:
11
+ # caller's actual argument types. Slice 4 phase 2c shape (with
12
+ # the v0.1.2 interface-strictness preference layered on top):
12
13
  #
13
14
  # 1. Filter overloads by positional arity (required, optional and
14
15
  # rest_positionals are honored; required_keywords disqualify the
15
16
  # overload because we do not yet thread keyword args through
16
17
  # `call_arg_types`).
17
- # 2. Within the arity-matching overloads, accept the first one
18
- # whose every (param, arg) pair returns a `yes` or `maybe`
19
- # answer from `Rigor::Type#accepts(arg, mode: :gradual)`.
20
- # 3. If no overload matches, fall back to `method_types.first`
21
- # so existing call sites keep their phase 1 / 2b behavior.
22
- # This preserves the fail-soft invariant of the dispatcher.
18
+ # 2. **Pass 1 strict matches first.** Among the arity-matching
19
+ # overloads, prefer the first one whose every (param, arg)
20
+ # pair returns a `yes` or `maybe` answer AND whose param
21
+ # types do NOT translate through `RBS::Types::Alias` /
22
+ # `Interface` / `Intersection`. The translator demotes those
23
+ # to `Dynamic[Top]`, which gradually accepts any argument
24
+ # so without this preference, an alias-typed overload like
25
+ # `Array#[](::int) -> Elem` would beat the strict
26
+ # `Array#[](Range) -> Array[Elem]?` overload for a Range
27
+ # argument. (Surfaced during v0.1.1 self-analysis; see the
28
+ # "Interface-strictness on overload selection" item in
29
+ # `docs/MILESTONES.md`.)
30
+ # 3. **Pass 2 — gradual fall-back.** If no fully strict overload
31
+ # matches, accept the first arity-and-gradual-accept match
32
+ # (the v0.1.1 behaviour). Alias / Interface / Intersection
33
+ # params still reach this pass, so call sites whose only
34
+ # candidate IS an alias-typed overload keep working.
35
+ # 4. If no overload matches at all, fall back to
36
+ # `method_types.first` so existing call sites keep their
37
+ # phase 1 / 2b behavior. This preserves the fail-soft
38
+ # invariant of the dispatcher.
23
39
  #
24
40
  # The selector is intentionally agnostic about the dispatch kind
25
41
  # (instance vs singleton). Both kinds share the same arity and
26
42
  # acceptance shape; the difference is only in which `Definition`
27
43
  # the caller fetched.
28
- module OverloadSelector
44
+ module OverloadSelector # rubocop:disable Metrics/ModuleLength
29
45
  module_function
30
46
 
31
47
  # @param method_definition [RBS::Definition::Method]
@@ -61,6 +77,18 @@ module Rigor
61
77
  # compatibility.
62
78
  param_overrides = RbsExtended.param_type_override_map(method_definition)
63
79
 
80
+ # Pass 1: prefer overloads whose param types stay strict —
81
+ # no translator-induced `Dynamic[Top]` from Alias /
82
+ # Interface / Intersection. The pass is skipped
83
+ # entirely when any arg is `Dynamic[Top]` (literally
84
+ # `untyped`), because gradual acceptance against an
85
+ # untyped arg accepts every param indiscriminately and
86
+ # would let pass 1 lock in an arbitrary strict overload
87
+ # (e.g. `Regexp#=~(nil) -> nil` over the
88
+ # `(::interned?) -> Integer?` overload). Pass 2 falls
89
+ # back to the original gradual matcher so overloads
90
+ # that legitimately rely on duck-typed params still
91
+ # resolve when nothing stricter applies.
64
92
  match = find_matching_overload(
65
93
  overloads,
66
94
  arg_types: arg_types,
@@ -68,7 +96,17 @@ module Rigor
68
96
  instance_type: instance_type,
69
97
  type_vars: type_vars,
70
98
  block_required: block_required,
71
- param_overrides: param_overrides
99
+ param_overrides: param_overrides,
100
+ strict: true
101
+ ) || find_matching_overload(
102
+ overloads,
103
+ arg_types: arg_types,
104
+ self_type: self_type,
105
+ instance_type: instance_type,
106
+ type_vars: type_vars,
107
+ block_required: block_required,
108
+ param_overrides: param_overrides,
109
+ strict: false
72
110
  )
73
111
  return match if match
74
112
  return overloads.find { |mt| overload_has_block?(mt) } if block_required
@@ -84,11 +122,14 @@ module Rigor
84
122
  class << self
85
123
  private
86
124
 
87
- # rubocop:disable Metrics/ParameterLists
125
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
88
126
  def find_matching_overload(overloads, arg_types:, self_type:, instance_type:, type_vars:, block_required:,
89
- param_overrides:)
127
+ param_overrides:, strict:)
128
+ return nil if strict && arg_types.any? { |t| untyped_arg?(t) }
129
+
90
130
  overloads.find do |method_type|
91
131
  next false if block_required && !OverloadSelector.overload_has_block?(method_type)
132
+ next false if strict && !strictly_typed_params?(method_type, arg_types.size)
92
133
 
93
134
  matches?(
94
135
  method_type,
@@ -100,7 +141,58 @@ module Rigor
100
141
  )
101
142
  end
102
143
  end
103
- # rubocop:enable Metrics/ParameterLists
144
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
145
+
146
+ # Treats the literal `untyped` carrier (`Dynamic[Top]`)
147
+ # as too imprecise to drive a strict-pass match. Other
148
+ # `Dynamic`-wrapped types with a concrete static facet
149
+ # carry enough information to pick a sensible overload.
150
+ def untyped_arg?(type)
151
+ type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
152
+ end
153
+
154
+ # Returns true when every positional param the call
155
+ # site engages translates to a non-`Dynamic[Top]`
156
+ # carrier. Alias / Interface / Intersection RBS types
157
+ # all degrade to `Dynamic[Top]` per the translator's
158
+ # current shape — those gradually accept any arg, so
159
+ # an overload that includes one would beat strictly-
160
+ # typed alternatives in pass 2 of the selector.
161
+ def strictly_typed_params?(method_type, actual_count)
162
+ fun = method_type.type
163
+ return false unless arity_compatible?(fun, actual_count)
164
+
165
+ params = positional_params_for(fun, actual_count)
166
+ params.all? { |param| !alias_or_interface_param?(param.type) }
167
+ end
168
+
169
+ # Recursive: an Optional / Union wrapper is strict iff
170
+ # every member is strict. Type args of a ClassInstance
171
+ # are NOT walked — `Range[::int]` is a Range carrier
172
+ # at the param level; the alias only colours the
173
+ # element type, which is checked separately when the
174
+ # element is actually accessed.
175
+ #
176
+ # `RBS::Types::Bases::Any` (the explicit `untyped`
177
+ # keyword) is treated like Alias / Interface /
178
+ # Intersection — both translate to `Dynamic[Top]`,
179
+ # both gradually accept anything. A `(untyped) -> T`
180
+ # catch-all overload that comes after the strictly-
181
+ # typed ones must lose pass 1 so the typed overloads
182
+ # win when their param actually fits the arg.
183
+ def alias_or_interface_param?(rbs_type)
184
+ case rbs_type
185
+ when RBS::Types::Alias, RBS::Types::Interface,
186
+ RBS::Types::Intersection, RBS::Types::Bases::Any
187
+ true
188
+ when RBS::Types::Optional
189
+ alias_or_interface_param?(rbs_type.type)
190
+ when RBS::Types::Union
191
+ rbs_type.types.any? { |t| alias_or_interface_param?(t) }
192
+ else
193
+ false
194
+ end
195
+ end
104
196
 
105
197
  # rubocop:disable Metrics/ParameterLists
106
198
  def matches?(method_type, arg_types, self_type:, instance_type:, type_vars:, param_overrides:)
@@ -106,6 +106,14 @@ module Rigor
106
106
  discovered_def_nodes = build_discovered_def_nodes(root)
107
107
  seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
108
108
 
109
+ # v0.1.2 — per-class table of method visibilities
110
+ # (`:public` / `:private` / `:protected`). The
111
+ # `def.method-visibility-mismatch` CheckRule consults
112
+ # the table to flag explicit-non-self calls to a
113
+ # private user method.
114
+ discovered_method_visibilities = build_discovered_method_visibilities(root)
115
+ seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)
116
+
109
117
  table = {}.compare_by_identity
110
118
  table.default = seeded_scope
111
119
 
@@ -340,7 +348,8 @@ module Rigor
340
348
  accumulator.transform_values(&:freeze).freeze
341
349
  end
342
350
 
343
- def walk_methods(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
351
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
352
+ def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
344
353
  return unless node.is_a?(Prism::Node)
345
354
 
346
355
  case node
@@ -356,6 +365,12 @@ module Rigor
356
365
  walk_methods(node.body, qualified_prefix, true, accumulator)
357
366
  return
358
367
  end
368
+ when Prism::ConstantWriteNode
369
+ if meta_new_block_body(node)
370
+ child_prefix = qualified_prefix + [node.name.to_s]
371
+ walk_methods(meta_new_block_body(node), child_prefix, false, accumulator)
372
+ return
373
+ end
359
374
  when Prism::DefNode
360
375
  record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
361
376
  return
@@ -370,6 +385,24 @@ module Rigor
370
385
  walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
371
386
  end
372
387
  end
388
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
389
+
390
+ # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
391
+ # / `Const = Struct.new(*sym) do ... end` constant write
392
+ # carries a block, the block body holds method overrides
393
+ # whose canonical class is `Const`. Returns the block body
394
+ # node (a `Prism::StatementsNode`) when the rvalue
395
+ # matches; nil otherwise. Used by `walk_methods` /
396
+ # `walk_def_nodes` to push `Const` onto the qualified
397
+ # prefix before recursing.
398
+ def meta_new_block_body(node)
399
+ return nil unless node.is_a?(Prism::ConstantWriteNode)
400
+
401
+ rvalue = node.value
402
+ return nil unless data_define_call?(rvalue) || struct_new_call?(rvalue)
403
+
404
+ rvalue.block&.body
405
+ end
373
406
 
374
407
  def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
375
408
  return if qualified_prefix.empty?
@@ -397,7 +430,8 @@ module Rigor
397
430
  accumulator.transform_values(&:freeze).freeze
398
431
  end
399
432
 
400
- def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity
433
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
434
+ def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
401
435
  return unless node.is_a?(Prism::Node)
402
436
 
403
437
  case node
@@ -413,6 +447,12 @@ module Rigor
413
447
  walk_def_nodes(node.body, qualified_prefix, true, accumulator)
414
448
  return
415
449
  end
450
+ when Prism::ConstantWriteNode
451
+ if meta_new_block_body(node)
452
+ child_prefix = qualified_prefix + [node.name.to_s]
453
+ walk_def_nodes(meta_new_block_body(node), child_prefix, false, accumulator)
454
+ return
455
+ end
416
456
  when Prism::DefNode
417
457
  record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
418
458
  return
@@ -422,6 +462,7 @@ module Rigor
422
462
  walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
423
463
  end
424
464
  end
465
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
425
466
 
426
467
  # v0.0.3 A — sentinel key under which `record_def_node`
427
468
  # files DefNodes that live outside any class / module
@@ -440,6 +481,134 @@ module Rigor
440
481
  accumulator[class_name][def_node.name] = def_node
441
482
  end
442
483
 
484
+ VISIBILITY_MODIFIERS = %i[public private protected].freeze
485
+
486
+ # v0.1.2 — per-class method-visibility table for the
487
+ # `def.method-visibility-mismatch` CheckRule.
488
+ #
489
+ # Tracks two visibility-changing forms:
490
+ #
491
+ # - **Modifier blocks**: a bare `private` / `protected` /
492
+ # `public` call inside a class body switches the
493
+ # "current default" visibility for every subsequent
494
+ # `def` until another modifier flips it again.
495
+ # - **Named-argument form**: `private :foo, :bar` (or
496
+ # the same with `protected` / `public`) marks specific
497
+ # names already-recorded under the class. Symbol-only
498
+ # args are recognised; `private def foo; end` (the
499
+ # wrap-around form) is not yet — it would need
500
+ # tracking the def-call's return-value visibility,
501
+ # which is a separate slice.
502
+ #
503
+ # Top-level (no surrounding class) defs do not contribute
504
+ # — Ruby's top-level visibility nuances (private at
505
+ # top-level marks the method on `Object`) are out of
506
+ # scope for v0.1.2.
507
+ def build_discovered_method_visibilities(root)
508
+ accumulator = {}
509
+ walk_method_visibilities(root, [], false, :public, accumulator)
510
+ accumulator.transform_values(&:freeze).freeze
511
+ end
512
+
513
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
514
+ def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
515
+ return current_visibility unless node.is_a?(Prism::Node)
516
+
517
+ case node
518
+ when Prism::ClassNode, Prism::ModuleNode
519
+ name = qualified_name_for(node.constant_path)
520
+ if name
521
+ child_prefix = qualified_prefix + [name]
522
+ walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
523
+ return current_visibility
524
+ end
525
+ when Prism::SingletonClassNode
526
+ if node.expression.is_a?(Prism::SelfNode) && node.body
527
+ walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
528
+ return current_visibility
529
+ end
530
+ when Prism::ConstantWriteNode
531
+ if meta_new_block_body(node)
532
+ child_prefix = qualified_prefix + [node.name.to_s]
533
+ walk_method_visibilities(meta_new_block_body(node), child_prefix, false, :public, accumulator)
534
+ return current_visibility
535
+ end
536
+ when Prism::DefNode
537
+ record_def_visibility(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
538
+ return current_visibility
539
+ when Prism::CallNode
540
+ updated = apply_visibility_call(node, qualified_prefix, current_visibility, accumulator)
541
+ return updated unless updated.equal?(current_visibility)
542
+ end
543
+
544
+ # Statement-position StatementsNode preserves
545
+ # left-to-right visibility flow; everything else
546
+ # recurses with the entry visibility unchanged.
547
+ if node.is_a?(Prism::StatementsNode)
548
+ local_visibility = current_visibility
549
+ node.compact_child_nodes.each do |child|
550
+ local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
551
+ local_visibility, accumulator)
552
+ end
553
+ else
554
+ node.compact_child_nodes.each do |child|
555
+ walk_method_visibilities(child, qualified_prefix, in_singleton_class, current_visibility, accumulator)
556
+ end
557
+ end
558
+ current_visibility
559
+ end
560
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
561
+
562
+ def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
563
+ return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
564
+ return if qualified_prefix.empty?
565
+
566
+ class_name = qualified_prefix.join("::")
567
+ accumulator[class_name] ||= {}
568
+ accumulator[class_name][def_node.name] = current_visibility
569
+ end
570
+
571
+ # Recognises modifier calls on the implicit-self receiver
572
+ # inside a class body. Returns the (possibly updated)
573
+ # current visibility:
574
+ #
575
+ # - `private` / `public` / `protected` (no args) —
576
+ # switch the running default for subsequent defs.
577
+ # - `private :foo, :bar` — back-patch the named methods
578
+ # in the accumulator. Returns `current_visibility`
579
+ # unchanged because the running default does NOT
580
+ # change for this form.
581
+ def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
582
+ return current_visibility unless call_node.receiver.nil?
583
+ return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
584
+ return current_visibility if qualified_prefix.empty?
585
+
586
+ args = call_node.arguments&.arguments || []
587
+ if args.empty?
588
+ call_node.name
589
+ else
590
+ apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
591
+ current_visibility
592
+ end
593
+ end
594
+
595
+ def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
596
+ class_name = qualified_prefix.join("::")
597
+ args.each do |arg|
598
+ name = visibility_target_name(arg)
599
+ next if name.nil?
600
+
601
+ accumulator[class_name] ||= {}
602
+ accumulator[class_name][name] = visibility
603
+ end
604
+ end
605
+
606
+ def visibility_target_name(arg)
607
+ return arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
608
+
609
+ nil
610
+ end
611
+
443
612
  # Registers the alias name in the `discovered_methods` table so
444
613
  # `undefined-method` diagnostics are not emitted for calls to the
445
614
  # aliased name. The kind mirrors the surrounding class context