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:
- 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.
- Phantom hub edges —
extend 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
Package: graphifyy 0.9.29 (PyPI) · Extractor:
graphify.extract.extracton.rbfiles · OS: macOS (Darwin 25.5.0) · Python: uv-managed CPythonSummary
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:
module Billing::TotalsConcern, its node is labeledBilling::TotalsConcern. Aninclude Billing::TotalsConcernin a model then resolves against neither the full path nor the last segment, and themixes_inedge is silently dropped. The same concern declared with nested syntax (module Billing/module TotalsConcern) is labeledTotalsConcernand resolves fine.extend ActiveSupport::Concernresolves its last segmentConcernagainst any local module that happens to be namedConcern(e.g.Naming::Concerndeclared nested-style). Every concern in the corpus then gets a spuriousmixes_inedge to that unrelated local module, which becomes a giant artificial hub.Minimal reproduction (5 files)
Runner:
Actual output
Invoice -> Billing::TotalsConcernis missing.Expected output
(and no
mixes_inedges targetingNaming::Concernfrom unrelated files —ActiveSupport::Concernis external and should either be an external node or no edge at all).Control experiment
Rewriting only
totals_concern.rbto the nested style:makes the edge appear:
The include statement in
invoice.rbis 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:include Namespace::SomeConcernedge from domain models to compact-declared concerns was silently dropped.mixes_inedges from every concern converged on the localConcernmodule.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. TreatActiveSupport::Concern(and other constants not declared in the corpus) as external rather than best-effort matching them to local nodes.Related
include/extend/prependmixin edges this report exercises — the resolution added there is what fails on compact-declared targets and what produces theActiveSupport::Concerncollision.stable_type_identifier) are invisible to structural extraction — dropped fromreferencesedges, and frominherits/mixes_inedges too #2055 describes the same last-segment blindness for qualified type names in Scala structural extraction; the Ruby resolver appears to share the defect class.