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
builds a resolved semantic index, while Prism
records the few facts whose exact syntax matters. Everything downstream is
plain data:
glob files -> parse syntax -> resolve semantics -> merge facts -> assign components -> evaluate rules
The optional archspec reflect command
boots Rails separately to record resolved association facts. Checks consume
those snapshots as data and reject them when their source inputs change.
- Collect. The patterns from
source(defaulting toapp/**/*.rb,lib/**/*.rb, and pack/engine paths) are globbed from the project directory, minusignorepatterns. (Analyzer.ruby_files) - Parse syntax. Each file goes through
Prism.parse_file. Syntax errors becomeparser.syntaxdiagnostics instead of crashes, andarchspec:disablecomments are collected as suppressions. - Resolve semantics. Rubydex indexes exactly those files and resolves
declarations, constants, ancestors, methods, and aliases.
(
RubydexIndex) - Merge facts. The Prism overlay adds literal
requirecalls, dynamic syntax, framework macros, and other source-shape facts Rubydex does not expose. (Analyzer::SyntaxOverlay) - Assign components. Each
componentdeclaration claims files by glob, constants by namespace/name, or classes by ancestry.except:subtracts from file globs. A file can belong to several components; theexplaincommand shows why. (Graph.assign_components) - 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.
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:
app/models/user.rb
defined constants: User
components:
models: matched file pattern app/models/**/*.rb
outgoing facts:
2:22 │ references UsersController
A Violation, Traced
Given models.cannot_use :controllers and this file:
class User
def profile_path = UsersController
end
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:
[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.