# ArchSpec — Full Documentation > Documentation for ArchSpec, the static-analysis architecture linter for Ruby and Rails applications. --- # Architecture linter for Ruby and Rails Canonical URL: https://archspecrb.dev/ Static architecture linter for Ruby and Rails and a Packwerk alternative. Declare components and boundaries in one file, then check every change in CI. --- # Getting Started Canonical URL: https://archspecrb.dev/getting-started/ After reading this guide, you will know: - How to install ArchSpec. - How to create an architecture file. - How to run checks in a Rails app. - How to read the first failure. ## Installation ArchSpec requires Ruby 3.2 or newer. Add ArchSpec to your Gemfile: ```ruby group :development, :test do gem "archspec" end ``` {: data-title="Gemfile"} Then install it: ```sh bundle install ``` ## Create the Spec Run: ```sh bundle exec archspec init ``` This creates `Archspec.rb`: ```ruby architecture :rails ``` {: data-title="Archspec.rb"} Add a fuller architecture when the app has a clear shape: ```ruby architecture :layered ``` Override the default directories when the app uses different names: ```ruby architecture :layered, layers: { interface: "app/controllers/**/*.rb", application: "app/services/**/*.rb", domain: "app/models/**/*.rb" } ``` ## Run It ```sh bundle exec archspec check ``` ArchSpec exits with `0` when the rules pass and non-zero when they fail. ## Read a Failure A failure shows the message and rule, the offending code, and the evidence ArchSpec found: ```text [error] models must not depend on controllers [dependencies.forbid] app/models/user.rb:2:3 1 │ class User < ApplicationRecord → 2 │ UsersController │ ^~~~~~~~~~~~~~~ 3 │ end note: User references UsersController ``` Run `explain` on the file to see how ArchSpec assigned it: ```sh bundle exec archspec explain app/models/user.rb ``` ```text app/models/user.rb defined constants: User components: models: matched file pattern app/models/**/*.rb outgoing facts: 1:14 │ inherits from ApplicationRecord 2:3 │ references UsersController ``` ## Commit the Spec Treat `Archspec.rb` like a test file. A rule should describe a boundary the team is willing to enforce. --- # Rails Applications Canonical URL: https://archspecrb.dev/rails/ After reading this guide, you will know: - What the Rails architecture checks. - How ArchSpec uses Rails conventions. - How to add stricter architecture checks. ## What ArchSpec Uses ArchSpec indexes Ruby source with Rubydex and uses Prism for a handful of syntax-specific facts. It does not boot Rails. In Rails apps, the useful evidence is the file layout: ```text app/controllers/users_controller.rb -> UsersController app/models/user.rb -> User app/services/billing/charge.rb -> Billing::Charge ``` That convention gives ArchSpec enough information to check many boundaries cheaply. ## The Rails Architecture ```ruby architecture :rails ``` The architecture defines controllers, models, helpers, mailers, jobs, and services. It also checks common boundaries: - Models should not depend on controllers or helpers. - Services should not depend on controllers or helpers. - Models and services should not call controller-only APIs such as `render`, `redirect_to`, `params`, `session`, `cookies`, or `flash`. ## Add More Structure Use another architecture when the app already has the matching folders: ```ruby architecture :layered ``` Override the default directories when the app uses different names: ```ruby architecture :layered, layers: { interface: "app/controllers/**/*.rb", application: "app/services/**/*.rb", domain: "app/models/**/*.rb" } ``` Architectures are ordinary rules written for conventional Rails paths. Add local rules beside them when the app has its own vocabulary. ## Check Names ArchSpec does not check Zeitwerk constant names. Zeitwerk does that itself. Add `Zeitwerk::Loader.eager_load_all` (or your loader's `eager_load`) to your test suite or CI: ```ruby Zeitwerk::Loader.eager_load_all ``` It raises on any file that does not define the constant its path implies, using your real inflector and ignores. ## Engines and Packs ArchSpec scans conventional engine and pack app paths by default: ```text engines/*/app/**/*.rb packs/*/app/**/*.rb ``` Define components for them when they are part of your architecture: ```ruby component :billing, in: "engines/billing/app/**/*.rb" component :catalog, in: "engines/catalog/app/**/*.rb" billing.cannot_use :catalog ``` --- # Checking AI-Written Code Canonical URL: https://archspecrb.dev/checking-ai-written-code/ After reading this guide, you will know: - Why architecture checks matter for generated code. - How to use ArchSpec after an AI-assisted change. - When to update the spec, todo file, or code. ## The Rule Generated code should pass the same architecture checks as hand-written code. ArchSpec helps because the rules are explicit and repeatable. A tool can write code quickly, but it does not know your team's boundaries unless those boundaries are executable. ## Review Flow After an AI-assisted change, run: ```sh bundle exec archspec check ``` If it fails, do not start by changing the spec. Read the evidence: ```text [error] services must not call #redirect_to [methods.forbid] app/services/create_user.rb:7:5 6 │ def perform → 7 │ redirect_to root_path │ ^~~~~~~~~~~~~~~~~~~~~ 8 │ end note: CreateUser calls redirect_to ``` Then ask: - Is the generated code in the right component? - Did it call a framework API from the wrong place? - Did it introduce a dependency direction the app does not allow? - Is the rule still true? Most failures should be fixed in the generated code. ## Use Explain ```sh bundle exec archspec explain app/services/create_user.rb ``` ```text app/services/create_user.rb defined constants: CreateUser components: services: matched file pattern app/services/**/*.rb outgoing facts: 3:5 │ calls create! 7:5 │ calls redirect_to ``` `explain` shows the defined constants, component assignment, suppressions, and outgoing facts. Use it when a failure looks surprising. ## Do Not Hide New Failures The todo file is for adopting ArchSpec in an existing app: ```ruby todo "archspec_todo.yml" ``` ```sh bundle exec archspec check --update-todo ``` Do not update the todo file to accept new generated code. Fix the code, or change the rule intentionally. ## Local Suppressions Use a local suppression only when the exception is deliberate and narrow: ```ruby class User # archspec:disable-next-line dependencies.forbid -- legacy admin export Admin::UsersController end ``` Prefer `disable-next-line` over block suppressions. The comment should say why the exception exists. --- # Vanilla Rails Guide Canonical URL: https://archspecrb.dev/vanilla-rails/ After reading this guide, you will know: - What vanilla Rails means as an architecture. - What `architecture :vanilla_rails` checks. - Which conventions ArchSpec cannot check. ## The Architecture 37signals builds Basecamp and HEY with rich models and thin controllers, and without the layers many Rails shops add on top: no service objects, no form objects, no policy objects, no presenters. Business logic lives on models. Plain Ruby objects live in `app/models` next to them. Jorge Manrubia describes the approach in [Vanilla Rails is plenty](https://dev.37signals.com/vanilla-rails-is-plenty/). The [37signals-skills](https://github.com/marckohlbrugge/37signals-skills) project collects these conventions as instructions for AI coding agents. Instructions only cover code generation. ArchSpec checks the code afterwards. ## What It Checks ```ruby architecture :vanilla_rails ``` {: data-title="Archspec.rb"} This runs the [Rails](/architectures/rails/) checks, then requires these directories to stay empty, using the [components rule](/rules/components/): | Directory | Reason | | ---------------- | ----------------------------------------------- | | `app/services` | behavior belongs on models, not service objects | | `app/forms` | use strong parameters and model validations | | `app/policies` | authorization is predicate methods on models | | `app/decorators` | use helpers and ERB partials | | `app/presenters` | presentation objects are POROs in `app/models` | | `app/components` | use helpers and ERB partials | If a change adds `app/services/create_user.rb`, the check fails: ```text [error] services must stay empty [components.empty] app/services/create_user.rb:1:1 → 1 │ class CreateUser │ ^ 2 │ def perform note: app/services/create_user.rb belongs to services reason: behavior belongs on models, not service objects ``` ## More Rules These conventions from the same playbook are also checkable. Add the ones your app follows. Jobs receive context as arguments instead of reading `Current` inside `perform`: ```ruby component :jobs, in: "app/jobs/**/*.rb" jobs.cannot_reference_constants "Current" ``` The same controllers serve HTML and JSON, so there is no API namespace: ```ruby component(:api_controllers, in: "app/controllers/api/**/*.rb") .must_be_empty(because: "the same controllers serve HTML and JSON via respond_to") ``` ## What ArchSpec Cannot Check Some vanilla Rails conventions are about design judgment, not file structure. ArchSpec does not check: - CRUD-only routing (`resource :closure` instead of `post :close`) - Concern size and cohesion - State as records instead of booleans (a `Closure` model vs `closed: true`) - Helpers taking explicit arguments instead of reading instance variables - Jobs being one-line delegations to model methods - Minitest and fixtures over RSpec and factories Use code review and agent instructions for these. --- # How It Works Canonical URL: https://archspecrb.dev/how-it-works/ After reading this guide, you will know: - The six stages between your source code and a diagnostic. - What facts ArchSpec extracts and what it refuses to guess. - Why checks are fast and safe to run anywhere. ## The Pipeline `archspec check` never loads or executes your application. [Rubydex](https://github.com/Shopify/rubydex) builds a resolved semantic index, while [Prism](https://github.com/ruby/prism) records the few facts whose exact syntax matters. Rubydex can't index ERB files so for them we rely purely on direct parsing. Herb provides a complete Prism program for each template. Everything downstream is plain data: ```text glob files -> parse syntax -> resolve semantics -> merge facts -> assign components -> evaluate rules ``` The optional [`archspec reflect` command](/association-reflection/) boots your application separately to record what Rails or another framework resolved at runtime. Checks consume those facts as data and report them as stale when the files they depend on change. 1. **Collect.** The patterns from `source` (defaulting to `app/**/*.rb`, `lib/**/*.rb`, and pack/engine paths) and component file patterns are globbed from the project directory, keeping `.rb`, `.rake`, and `.erb` files and excluding `ignore` matches. ERB templates need an explicit source or component pattern, such as `component :views, in: "app/views/**/*.erb"`. (`Analyzer.source_files`) 2. **Parse syntax.** Ruby and Rake files go through `Prism.parse_file`. For ERB, `Herb.parse(source, prism_program: true)` parses the template once. ArchSpec loads the document's serialized Prism program, retaining complete Ruby control flow and shared locals. Ruby syntax errors become `parser.syntax` diagnostics at the original source locations; HTML structure is not validated. `archspec:disable` comments are collected as suppressions both from embedded Ruby comments, extracted by Prism and from ERBComments (e.g. `<%# ... %>`) collected from the `Herb.parse` AST. Layout `yield` expressions are accepted, and their arguments are analyzed. 3. **Resolve semantics.** Rubydex indexes the collected `.rb` and `.rake` files and resolves declarations, constants, ancestors, methods, and aliases. ERB files are not sent to Rubydex's file index; their Ruby facts come from the Prism syntax overlay. (`RubydexIndex`) 4. **Merge facts.** The Prism overlay adds literal `require` calls, dynamic syntax, framework macros, and other source-shape facts Rubydex does not expose. Static concern modeling then assigns callback methods, mixins, and call receivers to their consumers. Finally, fresh facts from `archspec reflect` are validated and imported, and stale ones are reported. (`Analyzer::SyntaxOverlay`, `ConcernSemantics`, `Facts.load_into`) 5. **Assign components.** Each `component` declaration claims files by glob, constants by namespace/name, or classes by ancestry. `except:` subtracts from file globs and ancestry. A glob whose literal leading directories sit inside a default ignore, such as `vendor/engines/*/**/*.rb`, is collected despite it; your own `ignore` patterns still apply. A file can belong to several components; the `explain` command shows why. (`Graph.assign_components`) 6. **Evaluate.** Every rule reads the graph and emits diagnostics, which are then filtered through suppressions and the todo file, sorted, and printed. (`Evaluator.evaluate`) ## The Facts Everything a rule can check is one of these edge types: | Fact | Recorded when | | -------------------------- | ---------------------------------------------- | | `references_constant` | `UsersController` appears in an expression | | `inherits_from` | `class User < ApplicationRecord` | | `includes` / `prepends` / `extends` | `include Billable` and friends | | `calls_named_method` | any method call, by name | | `instantiates_and_invokes` | `UserBuilder.new(params).build` | | `requires` / `requires_relative` | `require "csv"` with a literal string | | `dynamic_feature` | `send`, `const_get`, `define_method`, `method_missing`, ... | Alongside edges, the graph keeps each constant's methods, signatures, aliases, and mixins. Static constant receivers are resolved for receiver-aware method rules without materializing RubyDEX's entire method-reference graph. A definition is a `class` or `module` keyword, or a constant assignment. `MAX_RETRIES = 3` defines a plain constant; assigning `Class.new`, `Struct.new`, or `Data.define` defines a class whose block is its body, and `Module.new` defines a module. All of them belong to components and resolve as reference targets like any other constant. A class from `Struct.new` or `Data.define` has `Struct` or `Data` as its superclass, so `descendants_of: "Data"` selects it. Run `archspec explain app/models/user.rb` to see the facts for one file. It prints the defined constants, component assignments with reasons, and every outgoing edge, incoming dependency, and local analysis gap: ```text app/models/user.rb defined constants: User components: models: matched file pattern app/models/**/*.rb outgoing facts: 2:22 │ references UsersController ``` Facts captured at runtime are marked with their producer, such as `references Customer (from rails facts)`. See [Framework integrations](/framework-integrations/) to write a producer for another framework. ## A Violation, Traced Given `models.cannot_use :controllers` and this file: ```ruby class User def profile_path = UsersController end ``` {: data-title="app/models/user.rb"} Rubydex resolves a `references_constant` edge from `app/models/user.rb` to `UsersController`. Component assignment puts the file in `models` and puts `UsersController` (defined in `app/controllers`) in `controllers`. The `dependencies.forbid` rule walks dependency edges from `models`, resolves `UsersController` the way Ruby would, innermost namespace outward, finds it lands in a forbidden component, and emits: ```text [error] models must not depend on controllers [dependencies.forbid] app/models/user.rb:2:22 1 │ class User → 2 │ def profile_path = UsersController │ ^~~~~~~~~~~~~~~ 3 │ end note: User references UsersController ``` ## Dynamic Code Static analysis cannot see through `send`, `const_get`, or `method_missing`. ArchSpec records these as `dynamic_feature` facts with confidence `unknown_due_to_dynamic_feature` instead of ignoring them, and every diagnostic carries the evidence it was derived from, so you can verify a report against the source line it points to. Because checks only index code and never load it, they need no Rails boot, no database, and have no side effects. They are safe to run in CI, in a git hook, or after an AI-assisted change. --- # Framework integrations Canonical URL: https://archspecrb.dev/framework-integrations/ Frameworks create dependencies and methods that source code only implies. A macro such as `uses :billing` may resolve `BillingService` by convention and define a `billing` method. ArchSpec cannot see that statically, so a **producer** observes it in the booted application and records it as facts. Checks then enforce those facts without booting anything. ```text archspec reflect: boot -> analyze source -> producer records facts -> archspec_facts/.yml archspec check: analyze source -> apply fresh facts -> rules ``` ArchSpec ships the [Rails producer](/association-reflection/). This guide shows how to write your own. ## Write a producer A producer is a name and a block. This one teaches ArchSpec about the `uses` macro above: ```ruby # lib/archspec/widgets_facts.rb ArchSpec.producer :widgets do sources "app/widgets/**/*.rb" boot do |root| require File.join(root, "config/environment") end capture do |facts| Widgets.registry.each do |widget, (name, service, site)| facts.reference(source: widget, target: service, location: site) facts.methods(owner: widget, names: name, location: site) end end end ``` Declare it in `Archspec.rb`, next to any other producers: ```ruby reflect :rails reflect :widgets, require: "./lib/archspec/widgets_facts" ``` A `require:` path that starts with a dot is relative to `Archspec.rb`; anything else is required from the load path, so a gem can ship its producer. The file is only loaded by `archspec reflect`, and by `check --reflect` or an `auto: true` producer when its facts need refreshing. ```sh bundle exec archspec reflect ``` ```text Updated archspec_facts/rails.yml: 193 references, 404 methods, 11 gaps, 2 resolved gaps (1.2s) Updated archspec_facts/widgets.yml: 1 reference, 1 method (0.4s) ``` Each producer runs in its own process, all of them in parallel, and writes its own file. Pass names to run only some: `archspec reflect widgets`. A failing producer reports its error, keeps its previous file, and does not stop the others. ```text [error] widgets must not depend on services [dependencies.forbid] app/widgets/report.rb:3:3 2 │ include Widgets → 3 │ uses :billing │ ^~~~~~~~~~~~~ note: Report references BillingService ``` ### Producer options | Option | Effect | | --- | --- | | `boot { \|root\| ... }` | Loads the framework. Runs before the source is analyzed. | | `capture { \|facts\| ... }` | Records facts through the builder. Required. | | `sources "glob", ...` | Analyzed files the producer inspects. Editing or adding one makes only its facts stale; removing one drops its facts. Files the facts cite are always covered. | | `inputs "glob", ...` | Other files whose change makes the whole document stale. Defaults to `config/**/*.{rb,yml,yaml}`, `Gemfile`, `Gemfile.lock`, `*.gemspec`, and `.ruby-version`. | | `env "NAME", ...` | Environment variables recorded when captured. A check that sets one to a different value treats the document as stale. Values are stored in plain text, so never name secrets. | `boot` and `capture` run with the producer as `self`. The block form with an argument, `ArchSpec.producer(:widgets) { |producer| producer.sources ... }`, also works. ## Record facts `capture` receives a `ArchSpec::Facts::Builder`. `facts.graph` is the analyzed source, the same graph checks see. Owners, sources, and targets can be the runtime class or module itself, its name, or a graph node. Locations can be a `[path, line]` pair (what `const_source_location` returns), a backtrace location recorded with `caller_locations`, anything with a `source_location`, or an `ArchSpec::SourceLocation`. A line location underlines the code on that line. | Method | Records | | --- | --- | | `reference(source:, target:, location:)` | A dependency from `source` on `target` | | `methods(owner:, names:, location:)` | Methods on `owner`; optional `scope:` (`:instance` or `:class`), `visibility:`, `signatures:`, `alias_target:`, `mode:`, `installation:` | | `mixin(owner:, target:, kind:, location:)` | `include`, `prepend`, `extend`, or `singleton_prepend`, with its dependency | | `gap(source:, message:, location:)` | Behavior the producer could not establish, shown in analysis gaps | | `resolve(source:, location:, message: nil, consumers: nil)` | Settles an existing static analysis gap at exactly that location; with `consumers:`, only for the includers you inspected | | `method_definition(owner:, definition:, installation: nil)` | Copies a graph method definition with its metadata | | `bind_receiver(edge:, receiver:, scope:)` | Resolves an existing receiverless call to a consumer | | `expose_methods(source:, owner:, scope:)` | Classifies a module's methods as another constant's API | The source and owner must be analyzed constants. A target can be anything, but component rules only apply to analyzed targets. A name defined in several files is ambiguous; pass the graph node in that case. Record only what the runtime confirms. When evidence cannot pin down a single declaration or target, record a `gap` at the owner rather than choosing a plausible answer from naming conventions. ## Freshness Every document fingerprints its inputs, the analyzed files its `sources` cover, and every file its facts cite. `check` compares them with the current files: - A changed global input, or a mismatched `env` value, makes the whole document stale. - A changed or added covered source file makes only the facts citing it stale. - A removed file takes the facts citing it with it; there is nothing left for them to describe. - A resolution recorded with `consumers:` is reported as stale for any class that includes the module after the capture. Stale facts are skipped and reported as `facts.stale` violations, so a stale check fails instead of passing on outdated evidence. The [Rails guide](/association-reflection/#keep-facts-current) shows what that looks like. ## How facts combine with source analysis Facts are added to the same graph as source facts, before components are assigned, so every rule treats them alike. - References and mixins are deduplicated against the existing edges. - Methods follow their `mode`: `if_missing` (the default) keeps an existing definition with the same name and scope, `append` adds another definition, and `define` replaces earlier definitions, except a later definition in the owner's own file. - Receiver bindings for one call are combined across documents and replace that call's previous interpretation after all methods and mixins are applied. - Conflicting method exposures for one module are rejected. - A `resolve` removes the matching static gap. Nothing else removes a gap: facts that happen to cover the same code leave it in place. Every document is validated before any fact is applied. An invalid document fails the check with a message naming the file and the problem. Facts about constants that exist only at runtime, such as classes created with `Class.new` or `const_set`, are not supported: every owner and source must be declared in analyzed source. Record a gap at the code that creates them. ## The facts file Producers write version 2 documents. You rarely need to read them, but the format is stable and plain YAML: ```yaml version: 2 producer: widgets env: {} inputs: ["config/**/*.{rb,yml,yaml}", Gemfile, Gemfile.lock, "*.gemspec", .ruby-version] sources: ["app/widgets/**/*.rb"] snapshot: app/widgets/report.rb: 3f2a... references: - {path: app/widgets/report.rb, line: 3, column: 3, end_line: 3, end_column: 16, source_path: app/widgets/report.rb, source: Report, target: BillingService} methods: - {path: app/widgets/report.rb, line: 3, column: 3, end_line: 3, end_column: 16, source_path: app/widgets/report.rb, owner: Report, names: [billing], scope: instance, visibility: public, mode: if_missing, signatures: []} ``` Every entry has a project-relative `path`, one-based `line`, `column`, `end_line`, and `end_column`, and a `source_path` for the file that defines its source or owner when that differs from `path`. Lists are `references`, `methods`, `mixins`, `receivers`, `exposures`, `gaps`, and `resolves`. Unknown fields, Ruby objects, and YAML aliases are rejected. A method signature has `required` and `optional` counts, `keywords` and `optional_keywords` lists, and `rest`, `keyword_rest`, `block`, and `forward` flags. A version 2 document without a `snapshot` is rejected, since nothing could tell when it went stale. Version 1 files, written by ArchSpec 1.1, are still read. They carry `references`, `methods`, and `gaps`, and go stale as a whole when any analyzed file or input changes, or when `RAILS_ENV` differs from their `environment`. Every `*.yml` file in the facts directory is loaded, in file name order. Each producer named by `reflect` must have written its `.yml`, so a check fails until a newly declared producer has run successfully. A file written without `archspec reflect`, for example by a script in CI, works the same way: ```ruby facts = ArchSpec::Facts::Builder.new(graph, producer: "script") facts.reference(source: "Invoice", target: "Customer", location: ["app/models/invoice.rb", 3]) ArchSpec::Facts.write("archspec_facts/script.yml", facts.to_document(sources: ["app/models/**/*.rb"])) ``` Build the graph with `ArchSpec::Analyzer.analyze(definition, root:, include_facts: false)` so it does not import older facts. ## Static framework modeling ActiveSupport concerns are handled without a producer. ArchSpec reads `included`, `prepended`, and `class_methods` blocks and installs their methods and mixins on each consumer through the same builder, in memory. Only conditional callbacks need the runtime, which the Rails producer covers. See [Concerns](/rules/concerns/). --- # Rails reflection Canonical URL: https://archspecrb.dev/association-reflection/ Some dependencies are not spelled out in the source. `belongs_to :customer` names a class through Rails conventions, `validates :email, email: true` names `EmailValidator` the same way, and a concern can decide at runtime what it mixes into a model. ArchSpec can ask your booted application what Rails actually resolved, save that as facts, and check it statically from then on. ## Capture facts Add one line to `Archspec.rb`: ```ruby architecture :rails reflect :rails ``` Then run: ```sh bundle exec archspec reflect --environment test bundle exec archspec check ``` `reflect` starts a separate process, loads `config/environment.rb`, eager loads the application, and writes `archspec_facts/rails.yml`. Your application must be able to boot in the chosen environment; the usual boot requirements still apply. ArchSpec does not run migrations, connect to the database, or query records. `check` and `explain` only read the facts. They never load the application or Rails. `facts "archspec_facts"` without `reflect` also selects the Rails producer; use it to keep the facts in another directory. ## What the Rails producer records **Associations.** Each association's resolved target, using the application's real inflections, namespace lookup, `class_name`, and `through` options, becomes a reference located at its declaration. Generated readers and writers become methods on the model. Inherited associations are recorded once, on the model that declares them; associations declared in concerns belong to each model that includes the concern, with the location in the concern. **Custom validators.** `validates :email, email: true` and namespaced keys such as `"billing/iban": true` become references from the model to the validator class Rails resolved, at the `validates` call. Validators passed to `validates_with` are already visible in the source. Active Model and Active Record's own validators are skipped. **Conditional concern callbacks.** Static analysis treats a mixin or method inside a conditional `included` or `prepended` block as a gap, because it cannot know which branch runs. The producer inspects every consumer: a module the consumer really received, or a method whose definition is really that callback's, is recorded on that consumer, located at the declaration. When every consumer could be inspected and every observed effect had exactly one possible declaration, the gap is resolved and disappears from the analysis gaps. Otherwise it stays. Diagnostics point at the declaration, so suppressions and todo entries work as they do for source facts. `archspec explain` marks these facts with `(from rails facts)`. The producer never guesses. Dynamic association names, ambiguous redeclarations, polymorphic targets, unresolvable classes, and validators with no literal declaration are reported as analysis gaps. Polymorphic associations still get their confirmed readers and writers. ## Keep facts current Facts describe the source they were captured from. When it changes, `check` reports a `facts.stale` violation and stops using the affected facts: - **An edited model or concern.** The facts recorded from that file go stale. The Rails producer covers `app/models/**/*.rb` (including engines under `*/app/models/`) and every file its facts cite, so adding a model file also reports it. Edits anywhere else, such as a controller or a service, do not touch the facts. A fact that depends on another file only indirectly, such as a `through` association whose intermediate model changed, is caught through that model's own stale report. - **A new includer of a settled concern.** A conditional callback resolved from the captured consumers is reported as stale for any class that includes the concern later. - **A deleted file.** Its facts are dropped with it. - **Configuration or dependencies.** A change to anything under `config/`, the `Gemfile` or `Gemfile.lock`, a gemspec, or `.ruby-version` invalidates the whole file. - **A different environment.** The producer records `RAILS_ENV`. When a check runs with a different `RAILS_ENV`, the facts are stale. ```text [error] rails facts are out of date for this file; run `archspec reflect` [facts.stale] app/models/invoice.rb:1:1 ``` A stale fact never passes silently: the check fails until you regenerate. `--update-todo` refuses to run while facts are stale, and stale violations are never written to the todo. When you check specific paths, a stale file outside them is not reported, but stale configuration always is. Run reflection after changing models or configuration: ```sh bundle exec archspec reflect --environment test && bundle exec archspec check ``` Locally, `bundle exec archspec check --reflect` does both in one command and reruns the producer only when its facts are missing or stale. Declare `reflect :rails, auto: true` to make that the default for `check` and `explain`. Either way, the check boots the app whenever it refreshes facts. Environment variables other than `RAILS_ENV`, external services, database-driven configuration, and path dependencies outside the project are not fingerprinted. Regenerate when those change too. Facts describe the runtime that produced them, not every possible configuration. Commit `archspec_facts/` to keep checks independent of booting Rails, or regenerate it in a CI step that has the application's dependencies. A failed reflection keeps the previous file. ## Other frameworks Any library can supply facts through its own producer, run by the same `archspec reflect` command. See [Framework integrations](/framework-integrations/). --- # Architecture rules overview Canonical URL: https://archspecrb.dev/rules/ Rules turn an architecture decision into an executable check. Most rules are scoped to a component: ```ruby component :models, in: "app/models/**/*.rb" component :controllers, in: "app/controllers/**/*.rb" models.cannot_use :controllers ``` ArchSpec reports each violation with its message and rule id, the offending code, and the evidence it found. Use the rule id for suppressions. The JSON format (`--format json`) adds the confidence and the stable id the todo file matches on. ## Rule Families - [Dependencies](/rules/dependencies/) - [Methods](/rules/methods/) - [Naming](/rules/naming/) - [Protocols](/rules/protocols/) - [Objects](/rules/objects/) - [Constants](/rules/constants/) - [Cycles](/rules/cycles/) - [Components](/rules/components/) - [Concerns](/rules/concerns/) ## Suppressing a Rule Prefer a narrow suppression with a reason: ```ruby # archspec:disable-next-line dependencies.forbid -- legacy admin export Admin::UsersController ``` Suppression forms: ```text archspec:disable-next-line RULE -- reason archspec:disable-line RULE -- reason archspec:disable RULE -- reason archspec:enable RULE ``` --- # Dependencies Canonical URL: https://archspecrb.dev/rules/dependencies/ Use dependency rules when one component should or should not reference another component. ## Allow Only These Components ```ruby controllers.can_only_use :models, :services ``` Rule id: `dependencies.allow` `can_only_use` is an allowlist. References from `controllers` to any other declared component fail. When the target belongs to several components and one of them is on the list, the reference passes. ## Forbid Specific Components ```ruby models.cannot_use :controllers ``` Rule id: `dependencies.forbid` `cannot_use` is narrower. References to listed components fail; other declared dependencies are allowed unless another rule forbids them. When the target belongs to several components and one of them is on the list, the reference fails. ## Allow Only These Consumers ```ruby shared_kernel.can_only_be_used_by :billing, :catalog ``` Rule id: `dependencies.consumers` The inverse of `can_only_use`. Where `can_only_use` limits who a component may depend on, `can_only_be_used_by` limits who may depend on it. A reference from any other component fails. Use it to protect a shared kernel or a component with a deliberately narrow audience. The component may still reference itself. ## Public API ```ruby billing.public_api "packs/billing/app/public/**/*.rb" ``` Rule id: `dependencies.privacy` `public_api` marks part of a component as its front door. References from outside the component must resolve to a constant defined in a public file. Everything else in the component becomes private, and outside references to it fail. Name the public surface by constant or namespace when it does not map to a directory: ```ruby billing.public_api constants: "Billing::Api" billing.public_api namespace: "Billing::Public" ``` `constants` matches exact names. `namespace` matches a name and everything under it. Code inside the component may still reach its own private constants. ## What Counts ArchSpec checks visible dependency edges: - constant references - inheritance - `include` - `prepend` - `extend` It does not build a whole-program call graph. --- # Method boundary rules Canonical URL: https://archspecrb.dev/rules/methods/ Method rules are name-based. They are useful for Rails boundary checks and project style conventions. ## Forbid Calls ```ruby services.cannot_call :render, :redirect_to, :params, :session ``` Rule id: `methods.forbid` This catches method sends from files assigned to the component. By default a call matches whatever the receiver is, so `queries.cannot_call :update` flags `user.update` and `cache.update` alike. Pass `receiver: :none` to match only bare calls, where the receiver is implicit `self`: ```ruby services.cannot_call :render, :params, receiver: :none ``` `pdf.render` and `client.params` then pass, while a bare `render` or `params` fails. The `:rails` architectures use `receiver: :none` for the controller API. A bare call to a method the component defines, inherits, or generates with `attr_*`, Rails `attribute`, `alias_attribute`, `enum`, `store_accessor`, `delegate`, or an Active Record association macro is treated as a call to its own API and is not flagged. The association macros cover the reader and writer, plus the `build_`, `create_`, and `reload_` helpers on `belongs_to` and `has_one`. Pass a constant name to match a semantic class receiver and its descendants: ```ruby models.cannot_call :find_by_sql, receiver: "ActiveRecord::Base" ``` `User.find_by_sql` is caught when `User` descends from `ActiveRecord::Base`; `connection.find_by_sql` is not. ArchSpec resolves static constant receivers and refuses to guess the type of local variables. Calls through method aliases are matched to their target when the receiver can be resolved, so forbidding `destroy_all` also catches a resolved alias such as `Store.wipe`. ## Forbid Definitions ```ruby library.cannot_define :call ``` Rule id: `methods.define_forbid` This catches classes in the component that define the named method. Use it when the method name itself is a design smell in that component. --- # Protocols Canonical URL: https://archspecrb.dev/rules/protocols/ Protocol rules check that classes in a component expose an expected API through their own definitions, resolvable superclasses, or mixins. ## Require One Method ```ruby services.must_implement :call ``` Rule id: `protocol.must_implement` Check the class side with `scope: :class`. Class methods inherited from a superclass or acquired through `extend` count: ```ruby jobs.must_implement :perform_later, scope: :class ``` Require a callable shape with `arity:` and `keywords:`: ```ruby commands.must_implement :call, arity: 1, keywords: %i[actor request_id] ``` `arity:` means the method must accept that many positional arguments, so an optional or rest parameter can satisfy it. Required, optional, and rest keywords are handled the same way. A generated method whose signature is not known produces a medium-confidence finding rather than being assumed correct. ## Require One Of Several Methods ```ruby queries.must_implement_one_of :call, :resolve jobs.must_implement_one_of :enqueue, :perform_later, scope: :class ``` Rule id: `protocol.must_implement_one_of` These checks verify API shape, not behavior or return types. --- # Object usage rules Canonical URL: https://archspecrb.dev/rules/objects/ Use this rule when the project avoids classes that only exist to be instantiated and immediately invoked. ```ruby library.cannot_instantiate_and_invoke ``` Rule id: `objects.instantiate_and_invoke_forbid` This fails on code shaped like: ```ruby SuppressionParser.new(comments).parse UserBuilder.new(params).build ``` The usual replacement is a module API: ```ruby module SuppressionParser extend self def parse(comments) # ... end end SuppressionParser.parse(comments) ``` This rule is about one-shot object ceremony. It is not a general ban on objects. --- # Constants Canonical URL: https://archspecrb.dev/rules/constants/ Use constant rules when the boundary is best described by a framework or library constant. ```ruby models.cannot_reference_constants "ActionController", "ActionView" ``` Rule id: `constants.forbid` This fails when a file in the component references the constant or anything under it. For component-to-component boundaries, prefer [dependency rules](/rules/dependencies/). --- # Dependency cycle rules Canonical URL: https://archspecrb.dev/rules/cycles/ Cycles make architecture harder to change because two components become one unit in practice. ```ruby no_cycles ``` Rule id: `dependencies.no_cycles` Limit the check to specific components: ```ruby no_cycles among: %i[ui domain infra] ``` ArchSpec builds the component dependency graph from visible dependency edges and reports cycles such as: ```text component dependency cycle: domain -> infra -> domain ``` A group of components that all reach each other is reported once, not once per route through it. When the group is larger than the cycle shown, the message names every component in it and the note carries the shortest cycle to break: ```text component dependency cycle among 4 components: billing, catalog, orders, shipping note: billing -> orders -> billing ``` --- # Components Canonical URL: https://archspecrb.dev/rules/components/ Use this rule when the architecture decision is "this kind of code should not exist here at all." ```ruby component(:services, in: "app/services/**/*.rb") .must_be_empty(because: "behavior belongs on models") ``` Rule id: `components.empty` Every file that lands in the component fails the check: ```text [error] services must stay empty [components.empty] app/services/create_user.rb:1:1 → 1 │ class CreateUser │ ^ 2 │ def perform note: app/services/create_user.rb belongs to services reason: behavior belongs on models ``` The `because:` reason is optional but recommended. It appears in the failure and tells the reader where the code should go instead. Like every rule reason, it stays outside the diagnostic fingerprint. ## When To Use It Use it when a convention bans a whole category of objects. Vanilla Rails in the 37signals style has no service objects, form objects, or policy objects; `architecture :vanilla_rails` uses this rule for those directories. See the [Vanilla Rails guide](/vanilla-rails/). It also helps during migrations: once a legacy directory has been emptied, `must_be_empty` keeps it empty. --- # Concern independence rules Canonical URL: https://archspecrb.dev/rules/concerns/ A concern is a module mixed into other classes. It should not know which classes use it. ```ruby component :model_concerns, in: "app/models/concerns/**/*.rb" model_concerns.cannot_reference_includers ``` Rule id: `concerns.independence` ArchSpec finds every class that includes, prepends, or extends a concern, then flags the concern for referencing that class by constant. ```ruby module Chargeable def charge Order.create!(...) # Chargeable must not reference its includer Order end end class Order < ApplicationRecord include Chargeable end ``` A concern that names its includer couples the two and defeats the point of extracting it. Passing behavior through the instance, such as `self` or a method the includer defines, does not trigger the rule. Modules that explicitly `extend ActiveSupport::Concern` receive Rails concern semantics. `class_methods` blocks and nested `ClassMethods` modules provide the consumer's class API; naming checks classify those methods in `scope: :class`. Their signatures and visibility participate in protocol checks on consumers. An `include`, `prepend`, or `extend` directly inside `included` or `prepended` runs on the consumer, and does not make the concern itself an includer. ArchSpec applies the matching callback and follows dependencies between concerns. Constant references keep their Ruby lexical scope. Ordinary modules and direct module-body mixins retain ordinary Ruby behavior. This analysis does not execute callbacks. Conditional mixins and method definitions inside callbacks remain analysis gaps; it does not choose a runtime branch or infer methods from arbitrary callback execution. [Rails reflection](/association-reflection/) fills those gaps from the booted application: it records the modules and methods each consumer actually received from a conditional callback and resolves the gap when every effect can be attributed to one declaration. The `:rails_strict` and `:vanilla_rails` architectures apply this to `app/**/concerns/**/*.rb`. Override the glob with `concerns:`, or pass `concerns: false` to skip it. --- # Method naming rules Canonical URL: https://archspecrb.dev/rules/naming/ Naming rules check the shape and consistency of the methods a component defines. They start from `method_names`, select some of those methods by name, then assert something about them. Every check is name-based and exact, so it reports at `high` confidence. Rules judge a component's own **defined, public** methods. Inherited methods, methods generated by a gem, and private or protected methods are left alone. ## Selecting Methods `matching` selects methods whose name matches a regex: ```ruby models.method_names.matching(/\A(get|set)_/) ``` Pass `scope: :class` to select class methods instead of instance methods: ```ruby models.method_names(scope: :class).matching(/\Afind_/) ``` A named capture in the regex, such as `(?.+)`, is available to `requires`. ## Forbid Names ```ruby models.method_names.matching(/\A(get|set)_/).forbidden(because: "use attr readers or plain names") ``` Rule id: `naming.forbidden` Every selected method fails. Use it to ban a naming style outright. The `because:` text is reported as the rule's reason and stays outside the todo fingerprint. ## Require a Sibling ```ruby chat.method_names.matching(/\Awith_(?.+)/).requires("without_%{base}") ``` Rule id: `naming.requires` For each selected method, a sibling named by the template must exist. The template interpolates the named captures from the selector, so `with_model` requires `without_model` and `with_tools` requires `without_tools`. A method without its sibling fails. The sibling is looked up in the same component by default. Pass `on:` to require it in another component, and `scope:` to require it as a class method: ```ruby chat.method_names.matching(/\Awith_(?.+)/).requires("%{base}", on: agent, scope: :class) ``` This reads: every `with_x` instance method on `chat` must have a matching `x` class method on `agent`. The sibling counts whatever its visibility. Other pairings follow the same shape: ```ruby accounts.method_names.matching(/(?.+)!\z/).requires("%{base}") ``` ## Allowlisting Every rule takes `except:`, a reviewed list of method names to skip: ```ruby models.method_names.matching(/\Ais_/).forbidden(except: %i[is_a is_haml]) ``` Keep the list in `Archspec.rb` where it is reviewed, rather than reaching for an inline suppression. ## Scope `scope:` on `method_names` selects which methods a rule judges: `:instance` (the default) or `:class`. On `requires`, `scope:` instead sets the scope of the required sibling. --- # Architectures Canonical URL: https://archspecrb.dev/architectures/ Architectures are rule bundles. They define conventional components and apply dependency rules between them. They do not add a second analysis engine. `preset` is an alias for `architecture`. Use whichever word fits: `architecture :rails` for a structural bundle, `preset :ruby_conventions` for a convention pack. Available architectures: - [Rails](/architectures/rails/) - [Vanilla Rails](/architectures/vanilla-rails/) - [Layered](/architectures/layered/) - [Hexagonal](/architectures/hexagonal/) - [Clean](/architectures/clean/) - [Modular Monolith](/architectures/modular-monolith/) - [CQRS](/architectures/cqrs/) - [Event Driven](/architectures/event-driven/) - [Ruby Conventions](/architectures/ruby-conventions/) Use an architecture when your app already follows a clear folder or namespace convention. Use primitive [rules](/rules/) when the shape is custom. --- # Rails architecture preset Canonical URL: https://archspecrb.dev/architectures/rails/ Rails is the default Rails architecture bundle. ```ruby architecture :rails ``` This defines: - `controllers` - `models` - `helpers` - `mailers` - `jobs` - `services` It checks that models and services stay away from controller and helper dependencies, and that they do not call controller-only APIs such as `render`, `redirect_to`, `params`, `session`, `cookies`, or `flash`. Some apps share helper modules as plain utilities outside views. Allow that with `share_helpers`, which drops the models-and-services to helpers boundary while keeping the rest: ```ruby architecture :rails, share_helpers: true ``` Override the controller API list when a component legitimately defines one of those names, such as a service framework with its own `params`: ```ruby architecture :rails, controller_api: %i[render redirect_to session cookies flash] ``` ## Strict `rails_strict` adds a cycle check across the Rails components and a concern independence check on `app/**/concerns/**/*.rb`. ```ruby architecture :rails_strict ``` Point the concern check elsewhere with `concerns:`, or turn it off with `concerns: false`: ```ruby architecture :rails_strict, concerns: "app/models/concerns/**/*.rb" ``` --- # Vanilla Rails Architecture Canonical URL: https://archspecrb.dev/architectures/vanilla-rails/ Vanilla Rails is a Rails architecture for rich models and thin controllers, without service objects, form objects, policy objects, presenters, decorators, or view components. ```ruby architecture :vanilla_rails ``` It starts from [Rails](/architectures/rails/), then requires these directories to stay empty: - `app/services` - `app/forms` - `app/policies` - `app/decorators` - `app/presenters` - `app/components` See the [Vanilla Rails guide](/vanilla-rails/) for the reasoning and for project-specific rules you can add on top. --- # Layered architecture preset Canonical URL: https://archspecrb.dev/architectures/layered/ Layered architecture is an ordered list. Earlier layers may depend on later layers. Later layers may not depend back outward. ```ruby architecture :layered ``` This compiles to: - components named `interface`, `application`, and `domain` - allowlist dependency rules in layer order - a cycle check across the layers The default Rails-flavored layers are: ```ruby interface: "app/controllers/**/*.rb" application: %w[app/services/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb] domain: "app/models/**/*.rb" ``` Override them when the app uses different directories: ```ruby architecture :layered, layers: { interface: "app/controllers/**/*.rb", application: "app/services/**/*.rb", domain: "app/models/**/*.rb" } ``` --- # Hexagonal Canonical URL: https://archspecrb.dev/architectures/hexagonal/ Hexagonal architecture separates the application core from adapters. ```ruby architecture :hexagonal ``` This creates four components: - `application` - `domain` - `ports` - `adapters` The core rules are: - `domain` must not use `adapters` - `ports` must not use `adapters` - `application` can use `domain` and `ports` - `adapters` can use `application`, `domain`, and `ports` - no cycles across the four components The default Rails-flavored directories are: ```ruby application: %w[app/services/**/*.rb app/use_cases/**/*.rb] domain: "app/domain/**/*.rb" ports: "app/ports/**/*.rb" adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb app/infrastructure/**/*.rb] ``` Pass any of those keys to override the defaults. --- # Clean architecture preset Canonical URL: https://archspecrb.dev/architectures/clean/ Clean architecture is modeled as ordered layers: ```ruby architecture :clean ``` Dependencies move inward: ```text frameworks -> interface_adapters -> use_cases -> entities ``` This compiles to the same primitive rules as [Layered](/architectures/layered/), with names that match Clean Architecture vocabulary. The default Rails-flavored directories are: ```ruby frameworks: %w[app/controllers/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb] interface_adapters: %w[app/adapters/**/*.rb app/presenters/**/*.rb app/serializers/**/*.rb] use_cases: %w[app/use_cases/**/*.rb app/services/**/*.rb] entities: %w[app/entities/**/*.rb app/domain/**/*.rb app/models/**/*.rb] ``` Pass any of those keys to override the defaults. --- # Modular Monolith Canonical URL: https://archspecrb.dev/architectures/modular-monolith/ Use this when the app is organized into named packages, packs, engines, or bounded contexts. ```ruby architecture :modular_monolith, components: { billing: "packs/billing/**/*.rb", catalog: "packs/catalog/**/*.rb", shared: "packs/shared/**/*.rb" }, allow: { billing: %i[shared], catalog: %i[shared] } ``` Every component gets an allowlist. If a component is omitted from `allow`, it may not depend on any other declared component. Add `public` to force other packs through a front door instead of reaching into internals: ```ruby architecture :modular_monolith, components: { billing: "packs/billing/**/*.rb", catalog: "packs/catalog/**/*.rb" }, allow: { catalog: %i[billing] }, public: { billing: "packs/billing/app/public/**/*.rb" } ``` `catalog` may depend on `billing`, but only on constants defined under `billing`'s public files. See [Public API](/rules/dependencies/). ArchSpec also adds a cycle check across the declared components. ## Engines and Packs Engines and packs are the same topology: named packages under a parent directory. Rather than list them by hand, declare one component per subdirectory with `each_directory`. It resolves paths against the `Archspec.rb` location, so it works no matter where `archspec` runs from. ```ruby each_directory "engines/*" do |name, path| component name, in: "#{path}/**/*.rb" end component :shared, in: "engines/shared/**/*.rb" billing.can_only_use :shared catalog.can_only_use :shared no_cycles ``` `Archspec.rb` is plain Ruby, so you keep full control: skip a directory, rename a component, or attach different rules per package inside the block. --- # CQRS architecture preset Canonical URL: https://archspecrb.dev/architectures/cqrs/ CQRS is modeled as commands, queries, and optional read models. ```ruby architecture :cqrs ``` This checks: - commands do not depend on queries - queries do not depend on commands - queries do not call obvious mutating methods such as `save!`, `update!`, or `destroy!` - the declared components do not form cycles The default Rails-flavored directories are: ```ruby commands: "app/commands/**/*.rb" queries: "app/queries/**/*.rb" read_models: "app/read_models/**/*.rb" ``` Pass any of those keys to override the defaults. This is a structural check. It does not prove that every read and write path obeys CQRS at runtime. --- # Event Driven Canonical URL: https://archspecrb.dev/architectures/event-driven/ Event-driven architecture is modeled as events, publishers, and subscribers. ```ruby architecture :event_driven ``` This checks: - event definitions do not depend on publishers or subscribers - publishers only depend on declared events - subscribers only depend on declared events - the declared components do not form cycles The default Rails-flavored directories are: ```ruby events: "app/events/**/*.rb" publishers: "app/publishers/**/*.rb" subscribers: "app/subscribers/**/*.rb" ``` Pass any of those keys to override the defaults. This is intentionally narrow. It checks source-level coupling, not event delivery semantics. --- # Ruby Conventions Canonical URL: https://archspecrb.dev/architectures/ruby-conventions/ Ruby Conventions bundles the generic Ruby naming idioms and applies them to every public instance and class method in the project. ```ruby preset :ruby_conventions ``` `preset` is an alias for `architecture`, and reads better for a convention pack. Either works. This checks: - no `get_` or `set_` accessor prefixes, across all public methods - no `is_` predicate prefix (Ruby predicates end in `?`) `has_` is left alone, because Rails uses `has_many` and `has_one`, and `has_access?` style predicates are legitimate. It adds no components, so it composes with any other architecture: ```ruby architecture :rails architecture :ruby_conventions ``` The checks are name-based and exact, reported at `high` confidence. Project-specific conventions stay opt-in through the [naming rules](/rules/naming/), which is where the `with_x` / `without_x` pairing, the `supports_*?` ban, and cross-component correspondences live: ```ruby chat.method_names.matching(/\Awith_(?.+)/).requires("without_%{base}") ``` --- # Configuration Canonical URL: https://archspecrb.dev/configuration/ `Archspec.rb` is Ruby. ## Project ```ruby source "app/**/*.rb", "lib/**/*.rb" ignore "app/legacy/**/*.rb" todo "archspec_todo.yml" ``` {: data-title="Archspec.rb"} ArchSpec analyzes `.rb`, `.rake`, and `.erb` files matched by source or component patterns. The default source patterns select `.rb` files; opt into Rake tasks and ERB templates with source patterns such as `source "app/**/*.{rb,erb}", "lib/**/*.{rb,rake}"` or components such as `component :tasks, in: "lib/tasks/**/*.rake"` and `component :views, in: "app/views/**/*.erb"`. Ignore patterns apply to all three file types. Note that broad source or component patterns, such as `lib/tasks/**/*`, include all supported formats: `.rb`, `.rake` and `.erb` files. For ERB templates, Herb provides a complete Prism program with template source locations, which ArchSpec analyzes so diagnostics point to the original template. ArchSpec checks the Ruby code, not the HTML structure. See [How It Works](/how-it-works/) for the parsing pipeline. Todo ids are computed from the rule, path, message, and evidence, not the line number, so entries survive edits that shift code. Opt into runtime facts with `reflect`, which names the producers `archspec reflect` runs: ```ruby reflect :rails reflect :widgets, require: "./lib/archspec/widgets_facts" facts "config/archspec_facts" # optional; the default directory is archspec_facts ``` Add `auto: true` to let `check` and `explain` run that producer themselves when its facts are missing or stale, as `archspec check --reflect` does for every producer. That boots the application during a check, so leave it off where checks must not boot anything: ```ruby reflect :rails, auto: true ``` Otherwise, `check` and `explain` read the directory's `.yml` files without running the application. A missing directory is an error, and stale facts fail the check as `facts.stale` violations. `facts` alone selects the Rails producer. See [Rails reflection](/association-reflection/) and [Framework integrations](/framework-integrations/). ## Components ```ruby component :controllers, in: "app/controllers/**/*.rb" component :models, in: "app/models/**/*.rb" component :views, in: "app/views/**/*.erb" component :billing, namespace: "Billing" component :records, descendants_of: "ApplicationRecord" component :workflows, in: "app/models/**/*.rb", except: "app/models/**/*_workflow.rb" ``` `except:` subtracts from the `in:` patterns and from `descendants_of:`. Explicit `namespace:` and `constants:` selectors remain explicit. The same hash form works inside architecture options such as `layers:` and `components:`. Files matched by `in:` patterns are always analyzed, even when `source` does not cover them. ArchSpec ignores `.git`, `.bundle`, `node_modules`, `tmp`, and `vendor` by default, and those defaults step aside for an `in:` pattern whose literal leading directories, before the first `*`, `?`, `[`, or `{`, sit inside one of them. `in: "vendor/engines/*/**/*.rb"` analyzes the engines under `vendor/engines`, minus any `except:` matches. A broad pattern such as `in: "**/*.rb"` does not reach into `vendor`, `tmp`, or `node_modules`. Patterns you pass to `ignore` always win, including one that restates a default such as `ignore "vendor/**/*"`. Declare one component per subdirectory with `each_directory`, which is handy for engines and packs: ```ruby each_directory "packs/*" do |name, path| component name, in: "#{path}/**/*.rb" end ``` ## Architectures ```ruby architecture :layered, layers: { interface: "app/controllers/**/*.rb", application: "app/services/**/*.rb", domain: "app/models/**/*.rb" } ``` Architectures define components and rules together. See [Architectures](/architectures/). ## Dependency Rules ```ruby controllers.can_only_use :models, :services models.cannot_use :controllers shared_kernel.can_only_be_used_by :billing, :catalog ``` `can_only_use` is an allowlist for what a component may depend on. `cannot_use` forbids specific components. `can_only_be_used_by` is the inverse of `can_only_use`: it limits who may depend on the component. See [Dependency Rules](/rules/dependencies/). ## Method Rules ```ruby services.must_implement :call services.must_implement_one_of :call, :resolve services.cannot_call :render, :redirect_to, :params, :session jobs.must_implement :perform_later, scope: :class commands.must_implement :call, arity: 1, keywords: :actor models.cannot_call :find_by_sql, receiver: "ActiveRecord::Base" ``` RubyDEX resolves static constant receivers, class-side ancestry, method aliases, and method signatures for these checks. Calls through dynamic receivers remain unknown rather than guessed. See [Method Rules](/rules/methods/) and [Protocol Rules](/rules/protocols/). For projects that avoid anonymous command-object style APIs, forbid method definitions too: ```ruby library.cannot_define :call library.cannot_instantiate_and_invoke ``` See [Object Rules](/rules/objects/). ## Constant Rules ```ruby models.cannot_reference_constants "ActionController", "ActionView" ``` Use this when the dependency is better expressed as a framework constant than a component. See [Constant Rules](/rules/constants/). ## Cycles ```ruby no_cycles ``` `no_cycles` checks component dependency cycles. See [Cycle Rules](/rules/cycles/). ## Reasons Every rule-creating call accepts `because:`: ```ruby models.cannot_use :controllers, because: "models must remain independent of the request" ``` The reason is printed with each finding and included in JSON, but stays outside the todo fingerprint. Adding or editing a reason does not invalidate an existing todo file. Repeating the same merged rule with two different reasons is rejected when the configuration loads. ## Suppressions ```ruby # archspec:disable-next-line dependencies.forbid -- legacy export Admin::UsersController ``` Supported forms: ```text archspec:disable-next-line RULE -- reason archspec:disable-line RULE -- reason archspec:disable RULE -- reason archspec:enable RULE ``` Omit `RULE` to suppress all ArchSpec rules on that line or block. In ERB templates, suppressions work in ERB comments, comment-only Ruby tags (`<% # ... %>` or `<%- # ... -%>`), and inline Ruby comments: ```erb <%# archspec:disable-next-line dependencies.forbid -- legacy export %> <%= Admin::UsersController.name %> <%= User.count # archspec:disable-line dependencies.forbid %> <% # archspec:disable dependencies.forbid %> <%= User.count %> <% x = 1 # archspec:enable dependencies.forbid %> <%= User.count %> ``` The final expression is checked again after `enable`. Suppressions use physical template lines, and inline comments end at their ERB closing tag. Escaped tags such as `<%% # archspec:disable %>` are literal text and do not suppress checks. --- # ArchSpec command-line interface Canonical URL: https://archspecrb.dev/cli/ Show general or command-specific help without running a check: ```sh bundle exec archspec help bundle exec archspec help check bundle exec archspec check --help ``` ## init ```sh bundle exec archspec init bundle exec archspec init Archspec.rb --force ``` Creates a starter `Archspec.rb`. ## check ```sh bundle exec archspec check bundle exec archspec check --config config/architecture.rb bundle exec archspec check --format json ``` Runs all rules. The command exits non-zero when violations are found. Each violation prints as a diagnostic block: the message and rule id, the offending code with the exact span underlined, and the evidence ArchSpec found as a note. On a terminal the output is colored; set `NO_COLOR` to disable. ```text [error] models must not depend on controllers [dependencies.forbid] app/models/user.rb:9:5 8 │ def controller_peek → 9 │ UsersController │ ^~~~~~~~~~~~~~~ 10 │ end note: User references UsersController 1 architecture violation found. ``` A clean run prints one line: ```text ArchSpec passed: 391 files, 407 constants, 11061 facts checked. ``` Pass paths to report only violations in those files or directories. ArchSpec still analyzes the whole project, so cross-file dependencies resolve, but output is scoped to what you touched: ```sh bundle exec archspec check app/models/user.rb app/services ``` This is the fast loop after an agent or a person edits a few files. It cannot be combined with `--update-todo`. ## todo ```sh bundle exec archspec check --update-todo ``` Writes the current violations to the configured todo file. One entry covers every occurrence with the same id (fingerprint), so the count it reports can be lower than the number of violations. Use this for existing apps, not for accepting new regressions. Parse errors are never written to the todo; a file that does not parse has to be fixed. Each entry records the violation's stable id and the fields the id is built from: rule, path, message, and evidence. Line numbers are left out on purpose. They are not part of the id, so an edit that only shifts code leaves the file byte-identical, and entries sort by those same fields rather than by location. ```sh bundle exec archspec check --check-todo ``` Runs the normal check and additionally fails when the todo lists an entry that no current violation matches, because the violation was fixed or is now suppressed locally. These are exactly the entries `--update-todo` would drop, so a fixed violation cannot linger in the burn-down list. Obsolete entries are printed before the verdict; with `--format json` they appear under `obsolete_todo`. With path arguments, only entries under those paths are reported; entries that record no path, such as bare ids, are reported only by an unscoped run. The configured todo file has to exist. Use this in CI instead of regenerating the file and diffing it. It cannot be combined with `--update-todo`. ```sh bundle exec archspec check --reflect ``` Before checking, runs the declared producers whose facts are missing or stale, the same way `archspec reflect` does, then checks against the fresh facts. Producers whose facts are current are not run, so an unchanged project boots nothing. Their progress goes to standard error, so `--format json` output stays parseable. This boots the application, so keep it to local runs; in CI, commit the facts or run `archspec reflect` as its own step. Producers declared with `auto: true` behave this way without the flag. `explain` accepts `--reflect` too. ## reflect ```sh bundle exec archspec reflect --environment test bundle exec archspec reflect widgets bundle exec archspec reflect --config config/architecture.rb ``` Runs the producers declared with `reflect` in `Archspec.rb` (the Rails producer when only `facts` is declared), each in its own process and all in parallel. Each one boots its framework and writes `/.yml`. Pass producer names to run only those. `--environment` sets `RAILS_ENV` for the producers; otherwise it stays as it is, and the Rails producer defaults to `development`. Regenerate after changing the files a producer covers, configuration, or dependencies; `check` reports stale facts as `facts.stale` violations, unless it runs with `--reflect` or the producer is declared with `auto: true`. A failed producer keeps its previous file and makes the command exit 1. See [Rails reflection](/association-reflection/) and [Framework integrations](/framework-integrations/). ## explain ```sh bundle exec archspec explain app/models/user.rb bundle exec archspec explain Billing::Invoice ``` Shows what ArchSpec knows about a file or constant: defined constants, component assignment reasons, suppressions, resolved ancestry, outgoing facts, incoming dependencies, and analysis gaps. ```text app/models/user.rb defined constants: User components: models: matched file pattern app/models/**/*.rb suppressions: 2-3 │ dependencies.forbid -- migrating legacy coupling outgoing facts: 1:14 │ inherits from ApplicationRecord 2:3 │ calls has_many 9:5 │ references UsersController incoming dependencies: 4:7 │ references User analysis gaps: 1 unknown receiver ``` Explaining a constant shows where it is defined, its components, its superclass, and its methods: ```text User kind: class file: app/models/user.rb:1 components: models: defined in matched file superclass: ApplicationRecord resolved ancestors: ApplicationRecord unresolved ancestors: ActiveRecord::Base instance methods: controller_peek, summary class methods: find_active outgoing facts: 1:14 │ inherits from ApplicationRecord incoming dependencies: (none) analysis gaps: (none) ```