ArchUnitRuby

skill
Security Audit
Warn
Health Warn
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 5 GitHub stars
Code Warn
  • network request — Outbound network request in .github/workflows/ci.yml
  • network request — Outbound network request in .github/workflows/release.yml
  • network request — Outbound network request in benchmark/extraction.rb
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

ArchUnitRuby is an architecture testing library. Specify and ensure architecture rules in your Ruby app. Easy setup and pipeline integration.

README.md

ArchUnitRuby - Architecture Testing

ArchUnitRuby logo

License: MIT Build & tests GitHub stars
Gem downloads Ruby 3.3+

Enforce architecture rules in Ruby projects. Check dependency directions, detect circular
dependencies, enforce naming and location conventions, measure code quality, and generate
architecture reports as ordinary Ruby tests.

Inspired by the amazing ArchUnit library, but not affiliated with ArchUnit.

Setup · Use Cases · Features · API Guide · Documentation · Contributing

ArchUnitRuby turns a Ruby codebase into a dependency graph and lets you test that graph with rules
that read like English:

ArchUnit.project_files
        .in_folder('app/api/**')
        .should_not.depend_on_files
        .in_folder('app/database/**')

ArchUnitRuby provides file, layer, slice, graph-reporting, and metric APIs with a documented 1.0
compatibility baseline. It is tested on Ruby 3.3, 3.4, and 4.0 on Linux and Ruby 4.0 on Windows.
The gem is named archunit; check RubyGems for published-version
availability, since a development checkout can be ahead of the published gem.

ArchUnitRuby follows the shared ArchUnitEverything architecture and fluent-API standard used by
its siblings ArchUnitTS and
ArchUnitPython, adapted where Ruby idioms differ.

The documentation site combines this guide, a
curated API guide, and a searchable,
source-generated reference. The same site is rebuilt and link-validated in CI before deployment
from main, so the published reference follows the repository.

⚡ 5 min Quickstart

Installation

ArchUnitRuby requires Ruby 3.3 or newer. These instructions target the 1.0 series. Add it to your
test dependencies once the target version is published:

# Gemfile
group :test do
  gem 'archunit', '~> 1.0.0'
end

Then install it:

bundle install

Or install it directly with gem install archunit --version '~> 1.0.0'.

ClickGems download analytics show trends by
date, gem version, Ruby version, system, and country. Download totals count package fetches rather
than unique users, so repeated installs and automated clients may contribute to the total. The
RubyGems API provides the
current raw count and ClickGems provides the historical breakdown.

RSpec and Minitest integrations are optional; ArchUnitRuby does not install either test framework
for you.

Add tests

Create spec/architecture_spec.rb:

require 'archunit'

RSpec.describe 'architecture' do
  it 'keeps the API away from the database' do
    rule = ArchUnit.project_files.in_folder('app/api/**')
                   .should_not.depend_on_files.in_folder('app/database/**')

    expect(rule).to pass
  end
end

Run it like any other specification:

bundle exec rspec spec/architecture_spec.rb

CI Integration

Architecture specifications run with the rest of the test suite, so no dedicated CI integration
is required:

# GitHub Actions
- name: Run architecture tests
  run: bundle exec rspec spec/architecture_spec.rb

🚐 Setup

Explain why a rule exists

Append .because(...) to a completed file, layer, slice, or metric rule:

rule = ArchUnit.project_files.in_folder('app/api/**')
               .should_not.depend_on_files.in_folder('app/database/**')
               .because('Keep persistence out of the API')

expect(rule).to pass

The explanation appears once in a failed RSpec, Minitest, or ArchUnit.assert_passes assertion.
It is available as rule.rationale. Calling .because(...) returns a distinct frozen rule;
the original rule and direct check results are unchanged. Reasons must be non-blank strings.
Add the explanation after all selectors and modifiers.

Project Location

The project locator is optional. With no argument, ArchUnitRuby searches from the current directory
for a Gemfile or gemspec. Pass a directory or either marker file when analyzing another project:

ArchUnit.project_files('/workspace/my_app')
ArchUnit.project_files('/workspace/my_app/Gemfile')

Source discovery skips symlinked files and directories, including project-local aliases. This
avoids duplicate file identities and unintentionally parsing linked source outside the project.
Direct file-information extraction also checks the canonical source path against the project root.

Project-wide exclusions

Add a .archignore at the detected project root to keep generated or intentionally out-of-scope
Ruby files out of dependency graphs and file-based metrics:

# Architecture exclusions (not a full gitignore parser)
/db/migrate/
generated/
**/*_generated.rb

Blank lines and full-line # comments are ignored. Bare names match at any directory depth;
patterns containing / are project-relative, and a leading / anchors a pattern to the root.
A trailing / matches directories and their descendants, not similarly named files. Windows
separators are normalized. Only the root file is read; nested .archignore files are not inherited.

These patterns combine with the built-in exclusions or explicit exclude_patterns:; [] disables
the defaults, not .archignore. Effective pattern changes automatically select a fresh cached graph.
Source-code edits still require clear_cache: true in a long-lived process. The file is limited to
1 MiB, must be a regular non-symlink file, and is decoded as UTF-8 with invalid bytes replaced.
Negation (!pattern), NUL characters, and a bare / are rejected with ArchUnit::UserError.
No configuration or application code is executed.

Fluent Grammar

Every rule is built left to right from the same small grammar:

Stage Purpose Examples
Entry Choose the architectural vocabulary project_files, project_layers, project_slices, project_graph, metrics
Scope Select subjects; repeated scopes use AND in_folder, in_path, with_name, for_classes_matching
Mood Choose the expected direction should, should_not
Predicate State the policy have_no_cycles, depend_on_files, adhere_to_diagram
Object Select the target of a relational rule in_folder, matching, a layer or slice name
Terminal Execute or render check, to_json, export_as_html, measure

Building a rule is lazy and does not scan the filesystem. check, measure, snapshot/report
terminals, and export terminals perform the work. Builders are immutable, so a scope can be reused:

services = ArchUnit.project_files.in_folder('app/services/**')

cycle_rule = services.should.have_no_cycles
database_rule = services.should_not.depend_on_files.in_folder('app/database/**')

String patterns are anchored globs. * stays inside one path segment, ** crosses directories,
and ? matches one non-separator character. Most selectors also accept regular expressions;
defined_by_regex is the explicit regex form for slices, while in_file takes one exact path.
Paths are project-relative and normalized to / separators.

A scope matching zero files returns EmptyTestViolation; it does not silently pass. Opt out only
when an empty result is genuinely valid:

rule.check(ArchUnit::CheckOptions.new(allow_empty_tests: true))

🐣 Features

File Rules

File rules cover cycles, naming, location, internal dependencies, external modules, and custom
source predicates:

rules = [
  ArchUnit.project_files.in_path('lib/**/*.rb').should.have_no_cycles,
  ArchUnit.project_files.in_folder('app/services/**')
          .should.have_name('*_service.rb'),
  ArchUnit.project_files.in_folder('app/domain/**')
          .should_not.depend_on_external_modules.matching('faraday')
]

rules.each { |rule| ArchUnit.assert_passes(rule) }

A custom predicate receives an immutable FileInfo with path, name, extension, directory,
complete content, and non-blank lines_of_code:

rule = ArchUnit.project_files.in_folder('app/services/**')
               .should.adhere_to(
                 ->(file) { file.lines_of_code < 300 },
                 'services must stay below 300 non-blank lines'
               )

Layer Dependencies

Named layers express an allowlist or blocklist over groups of files:

rule = ArchUnit.project_layers
               .layer('api').defined_by('app/api/**/*.rb')
               .layer('services').defined_by('app/services/**/*.rb')
               .layer('database').defined_by('app/database/**/*.rb')
               .where_layer('api').may_only_depend_on_layers('services')
               .where_layer('services').may_only_depend_on_layers('database')
               .where_layer('database').may_only_depend_on_layers

expect(rule).to pass

Dependencies within one layer are always allowed. Edges with an unassigned endpoint are ignored.
Calling may_only_depend_on_layers without targets seals a layer; may_not_depend_on_layers
requires at least one forbidden target.

Architecture Slices and PlantUML

Slices group files by one captured path segment and preserve every concrete dependency as evidence:

slices = ArchUnit.project_slices.defined_by('lib/my_app/(**)/')
rule = slices.should_not.contain_dependency('api', 'database')

expect(rule).to pass

(**) is the slice capture. defined_by_regex uses the first regular-expression capture instead.

A checked-in PlantUML component diagram can also be the architecture contract:

rule = slices.should
             .ignoring_external_slices
             .adhere_to_diagram_in_file('docs/architecture.puml')

expect(rule).to pass

The supported subset recognizes components, directed dependencies, comments, and @startuml /
@enduml. Use to_plantuml or export_as_plantuml(path) to generate a diagram from the real graph.

Dependency Graph Reports

Graph reporting builds one immutable snapshot and renders it consistently as DOT, Mermaid, D2, CSV,
JSON, or self-contained HTML:

report = ArchUnit.project_graph
                 .include_external_dependencies
                 .focus_on('app/services/**', 2)
                 .collapse_to_folder_depth(2)
                 .titled('Service dependencies')

puts report.summary.node_count
report.export_as_html('reports/services.html')

Queries include focus_on, reachable_from, and dependents_of. Collapse by folder depth or a
regular-expression replacement. Every format has an in-memory to_<format> and an
export_as_<format>(path) terminal.

Code Metrics

Metric scopes select files and Ruby classes before measurement or assertion:

services = ArchUnit.metrics
                   .in_path('app/services/**/*.rb')
                   .for_classes_matching('*Service')

size_rule = services.count.method_count.should_be_below_or_equal(20)
cohesion_rule = services.lcom.lcom4.should_be(1)
distance_rule = services.distance.instability.should_be_below(0.8)

[size_rule, cohesion_rule, distance_rule].each { |rule| ArchUnit.assert_passes(rule) }

Count metrics cover class methods and fields plus file lines, statements, imports, classes, and
top-level functions. Cohesion includes LCOM96a, LCOM96b, LCOM1-5, and LCOM*. Dependency-derived
metrics include abstractness, instability, main-sequence distance, coupling factor, and normalized
distance. Zone guards detect the conventional zones of pain and uselessness.

Use measure for immutable numeric results, custom_metric for a calculation over ClassInfo, and
export_as_html for an offline metrics report:

services.count.export_as_html('reports/service-counts')

The threshold vocabulary is intentionally limited to should_be_below, should_be_above,
should_be, should_be_below_or_equal, should_be_above_or_equal, and should_satisfy.

Pattern Exclusions

Every selector accepts except: in the same call. A plain pattern or array uses the parent
selector's context, including filenames for path and folder selectors:

scope = ArchUnit.project_files.in_path(
  'app/**/*.rb',
  except: ['app/generated/**', 'schema.rb']
)

Use explicit targets when needed. Supported keys are in_path, in_folder, with_name, and
for_classes_matching:

scope = ArchUnit.metrics.in_path(
  'app/**/*.rb',
  except: { in_folder: 'app/generated', with_name: '*_spec.rb' }
)

🐹 Use Cases

Results and Test Frameworks

check returns an array of structured violations. Architecture disagreement is data, not an
exception:

violations = rule.check
violations.each { |violation| puts violation.class }

Translate that result into a test failure at the boundary that suits your suite:

expect(rule).to pass              # RSpec
assert_passes(rule)               # Minitest test case
ArchUnit.assert_passes(rule)      # Framework-neutral

ArchUnit.format_violations and ResultFactory provide stable human-readable output. All
violations retain the concrete dependency, file, layer, slice, or metric evidence that caused them.

📝 Debug Logging & Configuration

Logging is off by default and belongs to one check; there is no process-global configuration:

logging = ArchUnit::LoggingOptions.new(
  level: :debug,
  output_directory: 'tmp/archunit-logs',
  append: false
)

violations = rule.check(ArchUnit::CheckOptions.new(logging: logging))

Levels are debug, info, warn, and error. The fixed events cover check start/end, progress,
violations, and metric evidence. io: defaults to $stderr, accepts any writable stream, and may be
nil. File output creates missing directories and writes timestamped archunit-*.log files.

At :debug, inspection includes the project root, each discovered file, every dependency with
its import kind and external flag, selected files, and all metric calculations (including passing
values). It observes the same graph on both cold and cached checks. Custom metrics run once.
Check options and returned violations retain their existing behavior.

Level Included output
:debug Graph inspection, selections, metric values, and everything below
:info Check lifecycle and progress, violations, technical errors
:warn Violations and technical errors
:error Technical errors

Example debug lines (timestamp prefixes omitted):

[DEBUG] inspect: discovered file: "lib/service.rb"
[DEBUG] inspect: dependency: "lib/service.rb" -> "json" (external=true, kinds=[:require])
[DEBUG] inspect: selected file: "lib/service.rb"
[DEBUG] log metric: method_count=3 [lib/service.rb:Service]

For file-only CI artifacts, use io: nil with output_directory: and archive that directory.
Logging sink errors still propagate. Debug details are produced lazily; checks with logging
disabled create no output or log files.

Readable, colored failure reports

The same formatted, numbered reports work with RSpec, Minitest, or standalone checks:

puts ArchUnit.format_violations(violations)               # Detect terminal support
puts ArchUnit.format_violations(violations, color: true)  # Force ANSI colors
puts ArchUnit.format_violations(violations, color: false) # Plain CI/file output

Failure headings are bold red, violation headings yellow, and successful results green. Reports
include dependency, file, or metric evidence. Automatic color detection respects NO_COLOR,
TERM=dumb, and non-terminal output. Log files remain plain text for searching and CI artifacts.

🕵️ Technical Deep Dive

What ArchUnitRuby Extracts

ArchUnitRuby uses Prism and statically recognizes:

Ruby source form Import kind
require 'json' :require
require_relative '../models/order' :require_relative
autoload :Order, 'models/order' :autoload
load 'config/setup.rb' :load

Project dependencies use normalized, project-relative paths. Standard-library and gem dependencies
retain the module name written in source. Inline or immediately preceding ignore directives can
suppress known compatibility imports:

require 'legacy/client' # archunit: ignore legacy/client

# archunit: ignore experimental/plugin
require 'experimental/plugin'

Preceding local assignments to literal strings are also recognized for all four import forms:

path = '../retrieval/store'
require_relative path

Inference follows assignment order within straight-line lexical scopes. Reassignment replaces the
value; aliases, mutation, unknown calls receiving the value, reflection, and unsupported control
flow invalidate it. Values are not carried across branches, loops, exception handlers, or captured
blocks. Once a closure or reflective binding can access an enclosing scope, local inference remains
disabled for that scope. Independent literal assignments inside a method or block can still resolve. Runtime values
such as ENV.fetch('PLUGIN_PATH'), interpolation, and method results remain unresolved. No source,
Gemfile, gemspec, initializer, or const_missing hook is executed.

Constant References (Opt-in)

For autoloaded applications, enable dependencies from known project constants alongside imports:

options = ArchUnit::CheckOptions.new(dependency_analysis: :imports_and_constants)

# Detects Retrieval::Store.new even without require_relative.
rule = ArchUnit.project_files
               .in_path('app/api/**')
               .should_not.depend_on_files.in_path('app/retrieval/**')
violations = rule.check(options)

report = ArchUnit.project_graph.with_check_options(options)
puts report.to_json
puts ArchUnit.project_slices.defined_by('app/(**)/').to_plantuml(options)
values = ArchUnit.metrics.distance.instability.measure(options)

The default remains dependency_analysis: :imports. Expanded mode indexes declarations from the
same included source files, then resolves references to definition files. Files remain graph nodes.
It supports lexical nesting, compact namespaces, root-qualified names, and explicit superclass,
include, prepend, and extend references. Nested module A; module B includes A in lexical
lookup; top-level module A::B does not. A nearer namespace shadows a root namespace even when its
requested child is missing.

Ordinary = and conditional ||= constant declarations are indexed, including qualified and
root-qualified forms. A conditional declaration records a possible definition file, not proof that
the assignment executes or its resulting value. Multiple candidate files stay ambiguous. Other
compound writes (&&=, operators) and multiple-assignment declarations are not inferred as new
definition owners; unsupported targets remain unresolved rather than inventing ownership.

References with multiple definition files (reopened classes/modules), missing definitions, and
dynamic definitions are omitted, never assigned to an arbitrary file. Alias = Original defines
Alias in its own file and records the reference to Original; lookup through aliases such as
Alias::Child is not followed. Inherited constant members and singleton-class lexical lookup are
not modeled. Unqualified root fallback is conservatively suppressed in scopes with an explicit
superclass, mixins, or dynamic constant operations. Use explicit ::Namespace::Constant references
or import rules where those limits matter. Static analysis does not establish runtime load order
or whether a conditional use executes. Qualified declaration owners are stabilized before lookup;
if they do not stabilize within eight index passes, the project contributes imports only and its
constant uses are counted as unresolved.

In expanded mode, an import and a constant use between the same files produce one logical edge.
Its import_kinds
includes :constant_reference; edge.evidence and JSON graph exports retain the referenced name,
source span, and definition line for constant uses. Import evidence retains its call span. CSV,
HTML, and diagram reports use the same merged graph; CSV/HTML distinguish dependency kinds.
# archunit: ignore continues to suppress imports only, not constant references. Import-only mode
leaves edge.evidence empty for compatibility with existing edge-value comparisons;
ArchUnit::Extraction.extract_imports retains call locations in both modes.

For diagnostics, pass an ArchUnit::Extraction::ExtractionProfile to
ArchUnit::Extraction.extract_graph(root, options: options, profile: profile). Its counters include
unresolved_imports, unresolved_constant_references, and ambiguous_constant_references; unknown
third-party constants do not become architecture violations. These counts describe a cold analysis;
a cache hit does not rerun diagnostics. Included malformed Ruby now raises
ArchUnit::SourceParseError < ArchUnit::TechnicalError in both modes and in AST metrics, rather
than silently returning a partial graph or zero counts. Its immutable diagnostics contain
path, one-based line and column, and the parser message; the extraction profile increments
malformed_files. Fix or explicitly exclude invalid files using the project source policy.
Fluent rule selectors do not limit graph discovery. Failed graph builds are not cached; after
editing previously cached valid source, the normal explicit clear_cache: true policy still applies.
This intentionally changes the 0.0.1 prototype's permissive behavior in the 1.0 API.

Expanded analysis uses a bounded Prism index and adds no runtime dependency. The design spike
considered RubyDEX, whose semantic index is broader but adds a
Rust/native extension and platform packaging requirements. Reusing our existing Prism parser keeps
the Ruby 3.3/3.4/4.0 Windows/Linux support contract; each included source is parsed once per graph
build. A later semantic backend can broaden the documented lookup subset. Lookup fixtures follow
the Ruby constant-scope documentation.

Repositories containing multiple gems are discovered statically: every *.gemspec beneath the
project root contributes its adjacent existing lib directory to feature resolution. Gemspecs are
never evaluated. Add non-standard source roots explicitly through the per-check options bag; paths
are relative to the project root, must remain inside it, and use the order given after the normal
top-level lib and project-root search locations:

options = ArchUnit::CheckOptions.new(
  load_paths: ['components/billing/source', 'plugins/search/lib']
)

violations = rule.check(options)
report = ArchUnit.project_graph.with_check_options(options)

Load-path choices and dependency-analysis mode affect graph caching. Equivalent normalized choices
reuse a cached graph, while a different mode or load-path set builds a separate graph. Use
clear_cache: true after changing files, constant definitions, or gemspec
layout within one process.

🐲 Example Repository

The ArchUnitRuby RAG test repository
is a small layered retrieval-augmented-generation application with two deliberate architecture
violations. Its test suite exercises the public APIs above against real Ruby source on Linux and
Windows.

ArchUnitRuby also dogfoods itself in spec/architecture_spec.rb: common is isolated, domain
modules cannot depend on one another, implementation files cannot depend on the public surface, and
the complete library graph must remain cycle-free.

🦊 Contributing

Start with CONTRIBUTING.md for the complete development and pull-request guide.

git clone https://github.com/LukasNiessen/ArchUnitRuby.git
cd ArchUnitRuby
bundle install
bundle exec rake
bundle exec rake docs
gem build archunit.gemspec --strict

bundle exec rake validates release metadata, then runs the randomized RSpec suite and RuboCop.
CI additionally enforces 98% line
and 90% branch coverage, runs the dogfooding rules explicitly, builds the documentation, loads the
public API with Ruby warnings, tests optional RSpec/Minitest integrations, builds and installs the
gem artifact, runs the external RAG fixture, and checks Ruby 3.3, 3.4, and 4.0 across Ubuntu and
Windows.

Cold and warm extraction can be profiled independently with a generated multi-gem corpus. The
benchmark reports stage timings, resolution-cache effectiveness, Ruby heap growth, and peak RSS on
platforms that expose it:

bundle exec ruby benchmark/extraction.rb

See the benchmark guide
for corpus controls, JSON output, and CI limits.

The separate real-world canary
checks pinned Rack, Rake, and RSpec production-library sources in both dependency modes, including
passing and deliberately failing rules, graph baselines, and cold/warm performance. It never runs
those projects or installs their dependencies, and is not scheduled automatically yet.

The implementation conventions and intended dependency directions live in AGENTS.md.

1.0 compatibility and migration

The documented entry points, fluent rule methods, options, public data/error types, and report
formats in the API guide form the 1.x
compatibility baseline. Additive capabilities may be introduced in minor versions; incompatible
public API changes require a new major version and migration notes. Private implementation helpers
are not part of that contract. Correctness fixes can reveal violations that earlier versions missed;
review the changelog when updating.

When upgrading from 0.0.1:

  • The default remains import-only. Explicitly opt into dependency_analysis: :imports_and_constants
    when you want the documented static constant-reference model; it may reveal additional violations.
  • Included malformed Ruby now raises SourceParseError < TechnicalError, rather than silently
    passing or returning zero AST counts. Fix or explicitly exclude invalid source files.
  • Project .archignore exclusions and skipped symlinked source entries can change the discovered
    file set. Empty selections are still violations by default; check selectors rather than blindly
    allowing empty tests.
  • Cached graph results still require explicit clearing after source edits in a long-lived process.

Stable API compatibility does not mean complete Ruby interpretation or exhaustive runtime detection.
The supported static-analysis boundaries below remain part of the contract.

📅 Plans and Current Limitations

  • Files are the primary vocabulary; opt-in constant analysis has the lookup limits described above.
  • Runtime-derived import paths cannot be resolved; safely known local literal strings are supported.
  • PlantUML support is a deliberately small component-diagram subset, not a complete UML parser.
  • .archignore supports exclusion globs, not negated gitignore patterns or nested ignore files.
  • Cycle traversal is iterative and tested on 10,000-node chains and rings. Enumerating every cycle
    can still be expensive in densely connected graphs; narrow the selected scope when necessary.
  • Method-call/protocol rules, a standalone CLI, presets, and adoption baselines are roadmap work,
    not claims made by the current 1.0 API.

💟 Community

Maintainers

Contributors

See everyone who has contributed on the GitHub contributors page.

Questions and feature ideas are welcome in GitHub Issues.
For usage help, responsible vulnerability reporting, and community expectations, see
SUPPORT.md, SECURITY.md, and CODE_OF_CONDUCT.md.

Star History

Star History Chart

📄 License

MIT

Reviews (0)

No results found