Skip to content

fix(npm/ruby): a def wrapped in a non-visibility call is not counted #1401

Closed

Description

@dekobon

Summary

#1255 fixed private def x by teaching the Ruby Npm walk to look
inside a visibility call's argument list. The root cause it names is
more general: a method node that is not a direct child of the class
body is not counted at all, so class_nm disagrees with
nom.functions_sum on the same space. Three shapes still hit it.

All measured with bca metrics --no-config -O json at fix/issue-1255.

1 — A def wrapped in a non-visibility call

class Memo
  memoize def cached; end
  def plain; end
end

class_nm = 1, nom.functions = 2. memoize def / deprecate def /
sig { ... }; def are real idioms; the method is simply absent from
nm.

2 — A doubly-wrapped visibility call

class N
  private public def x; end
end

class_nm = 0, class_npm = 0, nom.functions = 1. This is the same
keyword family #1255 handles, one nesting level deeper:
RubyClassBody::apply_visibility_call sees a Call argument, falls to
its symbol arm, resolves no name, and drops the declaration exactly as
before the fix. Ruby's answer is that x is private — public(def x)
returns :x and the outer private :x then demotes it — so a fix has
to declare through the inner keyword and then let the outer one refile
by name.

3 — A def inside a conditional or a begin

class Cond
  if RUBY_VERSION > "3.0"
    def modern; end
  else
    def legacy; end
  end
  private
  begin
    def wrapped; end
  end
  def plain; end
end

class_nm = 1, nom.functions = 4.

Direction

The general rule would be "any Method / SingletonMethod reachable
from a body-level statement counts, taking its visibility from the
innermost recognised keyword, or from the body-wide flag when there is
none". That is a bigger change than #1255's arm and needs a decision on
how deep to descend — a def inside a def must not be counted at
class scope, and neither should one inside a do block that opens its
own space.

Until then, the class_nm ↔ nom.functions_sum parity holds only for
declarations at body level or wrapped in a visibility call. Found by
the independent review of the #1255 fix.

Status after R06 (#1696)

The walk reads the FuncOpener row under each argument's own chain (PR #1717); shapes 1 and 2 are two places it does not yet ask — see the comment of that date.


Resolution

Status: Fixed (pending merge)
Commit: 6b1dbd1 on branch fix/issue-1401 (integration branch fix/batch-2026-10-25)
Root cause: the member walk read the body's direct children and a visibility call's arguments only. Fixed with an explicit-stack walk that reads every statement position at class scope (conditionals, case arms, loop bodies, begin/rescue/ensure, modifiers, parentheses, assignment right sides) and every call's argument list, composing nested visibility calls per Ruby 3.1+, and stops at a nested def / block / lambda. The attribute walk's identical gap is #1719.

Activity

  1. dekobon commented on Oct 26, 2026

    @dekobon
    OwnerAuthor

    Status after R06 (#1696, PR #1717)

    Still open on the PR's head: both repros measure as before — memoize def cached gives class_nm = 1 against nom.functions = 2, and private public def x gives class_nm = 0 against nom.functions = 1.

    What changed is the code the fix lands in. RubyClassBody::visit and apply_visibility_call (src/metrics/npm/ruby.rs) now read the FuncOpener row through func_opener::is_callable and ask it under each argument's own chain (Ancestors::of_children on the call, then on its argument_list), so "a method node that is not a direct child of the body" is already answered by the row wherever the walk asks. The two gaps are therefore just two places the walk does not yet ask:

    1. Shape 1 (memoize def): visit's Call | Call2 | Call3 | Call4 arm hands only visibility calls to apply_visibility_call; a non-visibility call's named arguments are never asked. Asking is_callable of them with the same chain, and declaring under the body-wide flag, is the whole fix.
    2. Shape 2 (private public def x): apply_visibility_call meets a Call argument, finds it is not callable, and hands it to refile_argument, which resolves no symbol. Recursing into a nested visibility call — declare through the inner keyword, then let the outer one refile by name, as the issue says Ruby does — is the fix, and the chain for the inner call's arguments is of_children one level further down.

    Shape 3 (sig { … }; def) is unchanged. The class_nm ↔ nom.functions_sum parity test in the issue would make a good perturbation target for both.

  2. dekobon commented on Oct 29, 2026

    @dekobon
    OwnerAuthor

    Fix Summary

    Root cause: RubyClassBody (src/metrics/npm/ruby.rs) read the class body's direct children and a visibility call's arguments, and nothing else. All three shapes in the issue — and several more the reviews found — were therefore invisible to class_nm while nom counted them.

    Changes:

    • src/metrics/npm/ruby.rs: rewritten as an explicit-stack walk. A frame is a node plus its live Children iterator: a statement wrapper (if/unless/elsif/else/then, case/when/in/else, while/until/for and their do body, begin/rescue/ensure, the four statement modifiers, rescue modifier on both sides, parenthesized statements, an assignment's right side) read at statement positions only — a condition, a case value, a when pattern, a loop header or an assignment's left side is never read, so a bare private there is a variable, not the marker — or a call's argument list, where a def declares under the call's keyword or the body-wide flag and a symbol names a method. Closing a call re-files what its arguments named; an instance visibility keyword hands the names up (Ruby 3.1+ set_visibility returns its argument), the class-method pair and ordinary calls hand up nothing. A def inside a def / block / lambda stays that space's, as nom has it. Declarations are indexed by (name, family) so re-filing is a lookup, not a scan.
    • big-code-analysis-ast/src/node/ancestors.rs: ChildAncestors::descend / ascend move one held chain down and back up (floor-guarded, containment debug_asserts), replacing a chain copy per level; two unit tests.
    • big-code-analysis-bench/src/shapes.rs, docs/development/benchmarking.md: npm/ruby-nested-if-defs (depth) and npm/ruby-wide-private-defs (width), both linear (1.04 / 1.03).
    • src/metrics/npm.rs: eleven new tests; one existing test moved nm 2 → 3 (a def under @cfg.private runs where it is written — ruby 3.0.2 confirms foreign is defined) and was renamed to say so.

    Decision on the issue's open question ("how deep to descend"): every statement position at class scope, every call's argument list, and no further — a def inside another def, a block or a lambda belongs to the space nom opens for it. The class_nm == nom.functions_sum parity the issue proposes therefore holds on a body with no nested space (pinned by ruby_class_nm_agrees_with_nom_on_a_flat_body) and not in general.

    Review findings taken (astra-review, review, audit-tests, algorithmic-performance): the recursive first draft overflowed the release binary at 5,000 nested calls — now iterative, pinned on a 512 KiB thread; re-filing was quadratic on private def × W — now indexed; rescue modifier, parenthesized statements, assignment right sides, unless/until and the if/until modifiers were missing; nested-keyword semantics are Ruby 3.1+ and documented as such; the rescue_modifier aliases the grammar declares but never emits are pinned unreachable; wmc and halstead.effort headroom on the new struct were met by hoisting the three stateless statement predicates (which #1719 should share) and the frame-close bookkeeping.

    Not fixed, filed: the attribute walk ruby_walk_class_body in npa/shared.rs still reads direct children only — #1719, with a FIXME at the predicates it should take over.

    Tests: 27-perturbation sweep, 25 discriminating; the two that do not (when-pattern / rescue-class positions, assignment left side) are defence in depth and say so in the code. Patch coverage 99.74% (387/388; project 98.78%). make chain-audit and make bench-scaling pass. No integration-snapshot movement (the corpora carry no Ruby).

    Evidence: ~/bca-evidence/1401/ (fixtures, ruby 3.0.2 oracle outputs, dumps, perturbation driver and log, bench log, coverage-llvm.json, the first draft's overflow and width probes under review-astra/).

    Commit: 6b1dbd1 on branch fix/issue-1401 (integration branch fix/batch-2026-10-25).

  3. dekobon commented on Oct 31, 2026

    @dekobon
    OwnerAuthor

    Evidence supplement (2026-10-31)

    The fix comment above cites a local evidence directory, a path on one machine. Three source comments in the tree rested on two files there. The ruby 3.0.2 oracle script and its output are now docs/development/ruby_visibility_oracle.md (re-run against the installed 3.0.2 today, identical). The other is the perturbation sweep, recorded here.

    The fix comment says "27-perturbation sweep, 25 discriminating". That was the sweep at review time. The retained log is a later, larger run against the merged src/metrics/npm/ruby.rs (6b1dbd14): 35 perturbations, 29 discriminating, 6 that fail no test. The driver removed one arm or inverted one guard per row from an in-memory copy and restored in finally; it is not in the tree because its anchors are tied to that revision's text. npm.rs and npm/ruby.rs cite this for the six 0 -> [] rows, the position exclusions whose widening no fixture can observe: a widened arm that reads a condition, loop header, case value or when pattern as a statement finds the same shadowed local or bare marker. The comments say those exclusions are chosen by role and unpinned rather than claiming coverage.

    == ordinary call arguments not read: 8 -> ['ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_decorated_initialize_stays_auto_private', 'ruby_deeply_nested_calls_do_not_recurse', 'ruby_def_in_a_decorator_call_counts', 'ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts', 'ruby_keyword_in_argument_position_is_read_as_elsewhere', 'ruby_nested_visibility_calls_compose', 'ruby_visibility_call_on_another_object_declares_but_does_not_refile']
    == nested call in an argument list not read: 4 -> ['ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_deeply_nested_calls_do_not_recurse', 'ruby_def_in_a_decorator_call_counts', 'ruby_nested_visibility_calls_compose']
    == nested visibility call returns nothing: 1 -> ['ruby_nested_visibility_calls_compose']
    == class-method keyword forwards its names: 1 -> ['ruby_nested_visibility_calls_compose']
    == no wrapper read through: 9 -> ['ruby_bare_keyword_in_an_expression_is_the_marker_unless_shadowed', 'ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts', 'ruby_def_under_a_conditional_counts', 'ruby_def_under_each_statement_wrapper_counts', 'ruby_destructured_for_and_rescue_bindings_shadow_the_keyword', 'ruby_keyword_in_argument_position_is_read_as_elsewhere', 'ruby_marker_inside_a_wrapper_sets_the_flag', 'ruby_shadowed_keyword_is_a_variable_in_every_position']
    == wrapper argument not read through: 1 -> ['ruby_def_in_a_wrapper_argument_counts']
    == rescue variable not shadowed: 1 -> ['ruby_destructured_for_and_rescue_bindings_shadow_the_keyword']
    == destructuring target not walked: 1 -> ['ruby_destructured_for_and_rescue_bindings_shadow_the_keyword']
    == loop bodies (Do) not read: 2 -> ['ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_def_under_each_statement_wrapper_counts']
    == modifier statements not read: 2 -> ['ruby_def_under_each_statement_wrapper_counts', 'ruby_shadowed_keyword_is_a_variable_in_every_position']
    == rescue modifier not read: 1 -> ['ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts']
    == parenthesised statements not read: 2 -> ['ruby_def_in_a_wrapper_argument_counts', 'ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts']
    == assignment right side not read: 5 -> ['ruby_bare_keyword_in_an_expression_is_the_marker_unless_shadowed', 'ruby_def_in_a_wrapper_argument_counts', 'ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts', 'ruby_destructured_for_and_rescue_bindings_shadow_the_keyword', 'ruby_keyword_in_argument_position_is_read_as_elsewhere']
    == assignment left side read as a statement: 0 -> []
    == shadowing local ignored (marker always fires): 3 -> ['ruby_destructured_for_and_rescue_bindings_shadow_the_keyword', 'ruby_keyword_in_argument_position_is_read_as_elsewhere', 'ruby_shadowed_keyword_is_a_variable_in_every_position']
    == nothing ever shadows: 3 -> ['ruby_destructured_for_and_rescue_bindings_shadow_the_keyword', 'ruby_keyword_in_argument_position_is_read_as_elsewhere', 'ruby_shadowed_keyword_is_a_variable_in_every_position']
    == unless not read: 1 -> ['ruby_def_under_each_statement_wrapper_counts']
    == until not read: 1 -> ['ruby_def_under_each_statement_wrapper_counts']
    == loop header read as a statement: 0 -> []
    == case value read as a statement: 0 -> []
    == when pattern / rescue classes read as statements: 0 -> []
    == rescue/ensure not read: 1 -> ['ruby_def_under_each_statement_wrapper_counts']
    == case clauses not read: 2 -> ['ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_def_under_each_statement_wrapper_counts']
    == conditions read as statements: 0 -> []
    == modifier condition read as its body: 0 -> []
    == decorated def declared with the keyword of nothing: 2 -> ['ruby_decorated_initialize_stays_auto_private', 'ruby_def_in_a_decorator_call_counts']
    == chain not moved into wrappers: 11 -> ['ruby_bare_keyword_in_an_expression_is_the_marker_unless_shadowed', 'ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_def_in_a_wrapper_argument_counts', 'ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts', 'ruby_def_inside_another_space_is_not_the_class_s', 'ruby_def_under_a_conditional_counts', 'ruby_def_under_each_statement_wrapper_counts', 'ruby_destructured_for_and_rescue_bindings_shadow_the_keyword', 'ruby_keyword_in_argument_position_is_read_as_elsewhere', 'ruby_marker_inside_a_wrapper_sets_the_flag', 'ruby_shadowed_keyword_is_a_variable_in_every_position']
    == refile index ignores the family: 2 -> ['ruby_a_class_method_keyword_does_not_republish_initialize', 'ruby_class_method_keywords_scope_to_singletons']
    == argument marker not read: 1 -> ['ruby_keyword_in_argument_position_is_read_as_elsewhere']
    == argument bindings not shadowed: 1 -> ['ruby_keyword_in_argument_position_is_read_as_elsewhere']
    == right_assignment_list not read: 1 -> ['ruby_keyword_in_argument_position_is_read_as_elsewhere']
    == rescue handler not read: 1 -> ['ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts']
    == refile covers later declarations too: 20 -> ['ruby_an_already_private_auto_private_name_is_not_flipped', 'ruby_argument_form_visibility_does_not_flip', 'ruby_class_method_keyword_governs_a_wrapped_def', 'ruby_class_method_keywords_scope_to_singletons', 'ruby_class_method_symbol_does_not_cross_the_singleton_boundary', 'ruby_class_nm_agrees_with_nom_on_a_flat_body', 'ruby_def_in_parentheses_a_rescue_modifier_or_an_assignment_counts', 'ruby_nested_visibility_calls_compose', 'ruby_protected_wrapping_call_is_not_public', 'ruby_public_keyword_wrapping_initialize_wins', 'ruby_public_symbol_republishes_initialize', 'ruby_redefinition_under_a_bare_marker_keeps_each_bit', 'ruby_symbol_argument_does_not_cross_the_singleton_boundary', 'ruby_symbol_argument_promotes_under_a_private_flag', 'ruby_symbol_argument_reads_non_identifier_method_names', 'ruby_symbol_array_argument_names_every_element', 'ruby_visibility_call_on_self_is_honoured', 'ruby_visibility_call_wrapping_def_counts_the_method', 'ruby_visibility_call_wrapping_def_reads_the_keyword_not_the_flag', 'ruby_visibility_refiles_every_declaration_of_a_name_in_order']
    == refile covers the first declaration only: 1 -> ['ruby_redefinition_under_a_bare_marker_keeps_each_bit']
    == refile reads nothing: 13 -> ['ruby_argument_form_visibility_does_not_flip', 'ruby_class_method_keyword_governs_a_wrapped_def', 'ruby_class_method_keywords_scope_to_singletons', 'ruby_class_method_symbol_does_not_cross_the_singleton_boundary', 'ruby_nested_visibility_calls_compose', 'ruby_public_symbol_republishes_initialize', 'ruby_redefinition_under_a_bare_marker_keeps_each_bit', 'ruby_symbol_argument_does_not_cross_the_singleton_boundary', 'ruby_symbol_argument_promotes_under_a_private_flag', 'ruby_symbol_argument_reads_non_identifier_method_names', 'ruby_symbol_array_argument_names_every_element', 'ruby_visibility_call_on_self_is_honoured', 'ruby_visibility_refiles_every_declaration_of_a_name_in_order']
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions