Skip to content

Replace virtus with regular accessors + testing type-check shim#574

Open
pnomolos wants to merge 1 commit into
whitesmith:mainfrom
pnomolos:ps.replace-virtus
Open

Replace virtus with regular accessors + testing type-check shim#574
pnomolos wants to merge 1 commit into
whitesmith:mainfrom
pnomolos:ps.replace-virtus

Conversation

@pnomolos

@pnomolos pnomolos commented Jun 11, 2026

Copy link
Copy Markdown

Why

virtus is unmaintained and pulls in a chain of abandoned gems (axiom-types, coercible, descendants_tracker, ice_nine) plus the deprecated thread_safe (superseded by concurrent-ruby). RubyCritic only used a tiny slice of virtus — an attribute DSL on two classes — so carrying that whole dependency tree wasn't justified.

Direct accessor were chosen after the original PR and some deliberation. A custom module was originally over dry-rb deliberately: dry-struct would just trade one transitive tree for another (dry-core, dry-types, dry-logic, zeitwerk...), which runs counter to the goal and to the codebase's recent direction of shedding/leaning out dependencies (e.g. preferring prism over the parser gem, dropping cucumber).

What changed

  • Switch Smell and AnalysedModule to plain accessors
  • Added a small "micro-Sorbet" type checking DSL that wraps Smell and AnalysedModule only in testing to guarantee types. If the project is interested in adopting Sorbet or Steep this can (and should) be dropped.
  • virtus runtime dependency removed; ostruct moved to a development dependency (only used in tests).
  • .reek.yml and CHANGELOG.md updated accordingly.

Verification

  • Full rake suite is green: tests, reek (0 warnings), and rubocop (no offenses).
  • The full virtus dependency chain is confirmed gone from the bundle.
  • Behavior verified faithful to virtus: mutable defaults duped per-instance, Float::INFINITY/Symbol defaults preserved, coercion applied on writers, and method overrides intact.

Checklist

🤖 Generated with Claude Code

@JuanVqz

JuanVqz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Hey @pnomolos, I like the idea of removing a dependency; I'm always all for it. Well, not always, it depends, haha.

I dug into this and noticed only a small set of classes use virtus. Your instinct is right, and your module faithfully keeps virtus's behavior. But what if we go a bit further and just use plain old attr_accessors instead?

The one thing virtus gave us beyond accessors was type coercion. I checked every place these typed attributes get assigned, and the value is always already the right type (complexity/coverage are Floats, duplication += node.mass is integer + integer, and status is always a Symbol).

So coercion never actually converts anything here, which means we can drop it without changing behavior.

Smell would look like:

    attr_accessor :context, :cost, :locations, :message,
                  :score, :status, :type, :analyser

    def initialize(attributes = {})
      @locations = []
      @status = :new
      attributes.each { |name, value| public_send("#{name}=", value) }
    end

And AnalysedModule:

    attr_accessor :coverage, :name, :pathname, :smells, :churn,
                  :committed_at, :complexity, :duplication, :methods_count

    def initialize(attributes = {})
      @coverage    = 0.0
      @smells      = []
      @complexity  = Float::INFINITY
      @duplication = 0
      attributes.each { |name, value| public_send("#{name}=", value) }
    end

Cleanup bonus baked in: smells_count, file_location, file_name, and line_count were declared as attributes and then immediately overridden by real methods. They just vanish from the accessor list, no longer declared twice.

@faisal I'd like to know your opinion on this, if you can.

@pnomolos

Copy link
Copy Markdown
Author

@JuanVqz More than happy to do that - I had considered it but decided to retain behaviour as close to the original as possible. If @faisal agrees I'll rework the PR as suggested 👍

@faisal

faisal commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

I dug into this and noticed only a small set of classes use virtus. Your instinct is right, and your module faithfully keeps virtus's behavior. But what if we go a bit further and just use plain old attr_accessors instead?

The one thing virtus gave us beyond accessors was type coercion. I checked every place these typed attributes get assigned, and the value is always already the right type (complexity/coverage are Floats, duplication += node.mass is integer + integer, and status is always a Symbol).

I like the thinking here. My only concern is if this is introducing an assumption for the future. What gives us confidence the types will continue to be correct?

Would it make sense add tests that confirm type alignment, so we'd catch a mismatch at development time without adding runtime overhead?

@pnomolos

Copy link
Copy Markdown
Author

I don't know what thoughts are around these parts in regards to Steep or Sorbet, but this screams at me as a prime candidate for that sort of thing.

If you're not interested, though, I can see about adding tests in order to confirm alignment without affecting the runtime 🤔

@JuanVqz

JuanVqz commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

For now, I think we can keep it simple, and I second adding tests to catch mismatched types.

But feel free to open a small PR for Steep or Sorbet, then we can check what maintainers think about it

btw, I see Claude is coauthoring these changes. I'm not sure if rubycritic's maintainers have an opinion on that

@pnomolos pnomolos force-pushed the ps.replace-virtus branch from 4af3db0 to 8760b53 Compare June 16, 2026 06:36
The `virtus` gem is unmaintained and drags in a chain of abandoned
transitive dependencies: axiom-types, coercible, descendants_tracker,
ice_nine, and the deprecated thread_safe (superseded by concurrent-ruby).
RubyCritic only used a tiny slice of virtus -- an `attribute` DSL on two
classes -- so carrying that whole tree was not justified.

Introduce a small internal `RubyCritic::Attributes` module that
reproduces only the used subset: an `attribute` class macro with
reader/writer generation, type coercion (Float/Integer/Array/Symbol),
per-instance duped mutable defaults, and a hash-accepting initializer.
`Smell` and `AnalysedModule` now include it instead of `Virtus.model`.
Drop the `virtus` runtime dependency and move `ostruct` to a development
dependency (only referenced in tests). Update `.reek.yml` and the
changelog accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

refactor: simplify Smell/AnalysedModule and add test-only type checking

Replace the internal attributes module (from the previous commit) with
plain `attr_accessor` plus a hash-accepting initializer, per PR feedback
to go simpler. This keeps the gem free of virtus and its abandoned
transitive dependencies without introducing any custom attribute DSL.

Add a test-only "micro-Sorbet" helper (test/support/type_check.rb) that
wraps the writers of these plain accessors and raises on unexpected types,
recovering the lightweight type safety virtus used to provide. It is
loaded only through test_helper, never required by production code, and
never shipped (the gemspec packages lib only). A spec value may be a
single class or a union, nil is always permitted, and late-bound class
names (e.g. FakeFS::Pathname) are matched by name.

Adjust .reek.yml/.rubocop.yml for the plain accessors (writable-attribute
and initializer smells) and remove the now-stale entries that referenced
the deleted attributes module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pnomolos pnomolos force-pushed the ps.replace-virtus branch from 8760b53 to a642b76 Compare June 16, 2026 06:42
@pnomolos pnomolos changed the title Replace virtus with an internal attributes module Replace virtus with regular accessors + testing type-check shim Jun 16, 2026
@pnomolos

Copy link
Copy Markdown
Author

I've updated this with plain accessors. For verifying the types, I chose a "micro-Sorbet" approach of a wrapper that wraps the accessors in the test environment and checks every write for the Smell and AnalysedModule classes.

As far as I can tell there's no guidance towards not using an LLM for this repository, but I'm open to rewriting the implementation (it's pretty close to what I would have built, anyway) if there's any pushback :)

@faisal

faisal commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

That does suggest we should actually have guidelines ... #576

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants