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. 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:
glob files -> parse syntax -> resolve semantics -> merge facts -> assign components -> evaluate rules
The optional archspec reflect command
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.
- Collect. The patterns from
source(defaulting toapp/**/*.rb,lib/**/*.rb, and pack/engine paths) and component file patterns are globbed from the project directory, keeping.rb,.rake, and.erbfiles and excludingignorematches. ERB templates need an explicit source or component pattern, such ascomponent :views, in: "app/views/**/*.erb". (Analyzer.source_files) - 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 becomeparser.syntaxdiagnostics at the original source locations; HTML structure is not validated.archspec:disablecomments are collected as suppressions both from embedded Ruby comments, extracted by Prism and from ERBComments (e.g.<%# ... %>) collected from theHerb.parseAST. Layoutyieldexpressions are accepted, and their arguments are analyzed. - Resolve semantics. Rubydex indexes the collected
.rband.rakefiles 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) - Merge facts. The Prism overlay adds literal
requirecalls, 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 fromarchspec reflectare validated and imported, and stale ones are reported. (Analyzer::SyntaxOverlay,ConcernSemantics,Facts.load_into) - Assign components. Each
componentdeclaration 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 asvendor/engines/*/**/*.rb, is collected despite it; your ownignorepatterns still apply. 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. 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:
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 to write
a producer for another framework.
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.