Skip to content

Ruby: include targets declared with compact syntax (module Foo::Bar) never resolve — mixin edge silently dropped; extend ActiveSupport::Concern collides with any local module named Concern (follow-up to #1668) #2302

Description

@jplot

Package: graphifyy 0.9.29 (PyPI) · Extractor: graphify.extract.extract on .rb files · OS: macOS (Darwin 25.5.0) · Python: uv-managed CPython

Summary

Two related defects in the Ruby structural extractor, both caused by include-target resolution matching on the bare last segment of the module path against node labels as declared:

  1. Dropped edges — when a concern is declared with the compact syntax module Billing::TotalsConcern, its node is labeled Billing::TotalsConcern. An include Billing::TotalsConcern in a model then resolves against neither the full path nor the last segment, and the mixes_in edge is silently dropped. The same concern declared with nested syntax (module Billing / module TotalsConcern) is labeled TotalsConcern and resolves fine.
  2. Phantom hub edgesextend ActiveSupport::Concern resolves its last segment Concern against any local module that happens to be named Concern (e.g. Naming::Concern declared nested-style). Every concern in the corpus then gets a spurious mixes_in edge to that unrelated local module, which becomes a giant artificial hub.

Minimal reproduction (5 files)

app/models/invoice.rb
app/models/account.rb
app/models/concerns/archivable_concern.rb
app/models/concerns/billing/totals_concern.rb
app/models/concerns/naming.rb
# app/models/invoice.rb
class Invoice < ApplicationRecord
  include Billing::TotalsConcern
end

# app/models/account.rb
class Account < ApplicationRecord
  include ArchivableConcern
end

# app/models/concerns/archivable_concern.rb
module ArchivableConcern
  extend ActiveSupport::Concern

  def archived?
    true
  end
end

# app/models/concerns/billing/totals_concern.rb  — COMPACT declaration
module Billing::TotalsConcern
  extend ActiveSupport::Concern

  def total
    0
  end
end

# app/models/concerns/naming.rb — nested module incidentally named "Concern"
module Naming
  module Concern
    extend ActiveSupport::Concern
  end
end

Runner:

from pathlib import Path
from graphify.extract import extract
result = extract(sorted(Path('.').rglob('*.rb')), cache_root=Path('.'))
labels = {n['id']: n.get('label') for n in result['nodes']}
for e in result['edges']:
    if e['relation'] == 'mixes_in':
        print(f"{labels[e['source']]} -> {labels[e['target']]}")

Actual output

Account -> ArchivableConcern            # correct
ArchivableConcern -> Concern            # phantom (ActiveSupport::Concern → Naming::Concern)
Billing::TotalsConcern -> Concern       # phantom (same collision)

Invoice -> Billing::TotalsConcern is missing.

Expected output

Account -> ArchivableConcern
Invoice -> Billing::TotalsConcern

(and no mixes_in edges targeting Naming::Concern from unrelated files — ActiveSupport::Concern is external and should either be an external node or no edge at all).

Control experiment

Rewriting only totals_concern.rb to the nested style:

module Billing
  module TotalsConcern
    ...
  end
end

makes the edge appear:

Account -> ArchivableConcern
ArchivableConcern -> Concern
TotalsConcern -> Concern
Invoice -> TotalsConcern                # now present

The include statement in invoice.rb is unchanged between the two runs, which isolates the trigger to the declaration syntax of the target module.

Impact on a real corpus

On a real-world Rails API (~200 Ruby files) where all concerns are declared compact-style and one local nested module is legitimately named Concern:

  • Every include Namespace::SomeConcern edge from domain models to compact-declared concerns was silently dropped.
  • Phantom mixes_in edges from every concern converged on the local Concern module.
  • Community detection then produced a lowest-cohesion community gluing several unrelated namespaces together via the phantom hub, and the generated report flagged a well-factored concern layer as a refactoring candidate — a misleading conclusion caused purely by extraction.

Suggested direction

Resolve include/extend targets against the fully qualified declared path (falling back to last-segment only when unambiguous), and normalize compact (module A::B) and nested (module A; module B) declarations to the same qualified node name. Treat ActiveSupport::Concern (and other constants not declared in the corpus) as external rather than best-effort matching them to local nodes.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions