Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- name: Test
run: bundle exec rspec

- name: Exercise dependency-analysis reproducers
run: bundle exec ruby scripts/check_dependency_analysis.rb

quality:
name: Quality and package — Ruby 4.0 on Ubuntu
runs-on: ubuntu-latest
Expand Down Expand Up @@ -73,6 +76,12 @@ jobs:
--files 200 --gems 4 --imports-per-file 3
--max-cold-seconds 30 --max-warm-seconds 2

- name: Constant-index performance smoke benchmark
run: >-
bundle exec ruby benchmark/extraction.rb --constants
--files 200 --gems 4 --imports-per-file 3
--max-cold-seconds 30 --max-warm-seconds 2

- name: Build documentation
run: bundle exec rake docs

Expand Down Expand Up @@ -123,6 +132,7 @@ jobs:
GEM_PATH: ${{ runner.temp }}/archunit-gem-home
run: |
gem install pkg/archunit.gem --no-document
ruby scripts/check_dependency_analysis.rb
ruby -e "
require 'json'
require 'stringio'
Expand Down
26 changes: 26 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ options = ArchUnit::CheckOptions.new(
allow_empty_tests: false,
clear_cache: false,
load_paths: ['components/billing/lib'],
dependency_analysis: :imports,
logging: logging
)

Expand All @@ -200,6 +201,31 @@ when an empty scope is intentionally valid. Set `clear_cache: true` after modify
project during one process. `load_paths` adds project-contained source roots without evaluating
gemspecs or running application code.

`dependency_analysis` defaults to `:imports`. Imports accept literal arguments and preceding local
literal-string assignments in supported straight-line scopes. Aliases, mutation/escape, closures,
reflection, and unsupported control flow invalidate inferred values; runtime paths are not guessed.

For autoloaded projects, opt into known project constant references alongside imports:

~~~ruby
options = ArchUnit::CheckOptions.new(dependency_analysis: :imports_and_constants)
violations = rule.check(options)
json = ArchUnit.project_graph.with_check_options(options).to_json
values = ArchUnit.metrics.distance.instability.measure(options)
~~~

Expanded analysis resolves supported lexical, compact, and root-qualified references to their
definition files. Explicit superclass and mixin references also contribute edges. Missing or
ambiguous definitions, inherited-member lookup, and dynamic metaprogramming stay unresolved under
the bounded contract in the main guide. Namespaces that fail to stabilize within eight index passes
fall back to import-only results. Unknown third-party constants do not create architecture violations.

Expanded edges include `:constant_reference` in `import_kinds` and retain `DependencyEvidence` with
names and source lines in `edge.evidence` and JSON reports. An import and a constant use supporting
the same file pair merge into one edge. Import-only edge evidence stays empty for compatibility;
`ArchUnit::Extraction.extract_imports` retains import call locations in both modes. The analysis
mode participates in graph-cache identity, and `clear_cache: true` rebuilds the symbol index.

## Errors and support

Invalid API input raises `ArchUnit::UserError` or `ArgumentError`; library or environment failures
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
- Log passing metric values without repeating custom calculations.
- Document verbosity, CI log artifacts, and existing colored violation reports.

- Add opt-in project constant-reference dependencies with lexical lookup and conservative ambiguity handling.
- Infer import paths from safely known local literal strings without evaluating application code.
- Preserve dependency source evidence in expanded mode through graph merging and JSON reporting,
while keeping import-only edge values compatible with existing consumers.
- Support check options on metric measurements and add installed-gem extraction regression checks.
- Make trailing folder globs such as `app/api/**` include the named folder itself.
- Validate the README quickstart as a passing and failing end-to-end architecture test.
- Add a curated API guide, reliable Markdown rendering, responsive documentation navigation, and
Expand Down
84 changes: 78 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,79 @@ require 'legacy/client' # archunit: ignore legacy/client
require 'experimental/plugin'
~~~

Dynamic imports such as `require dependency_name` or `require "plugins/#{name}"` are omitted rather
than guessed because resolving them would require executing application code.
Preceding local assignments to literal strings are also recognized for all four import forms:

~~~ ruby
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:

~~~ ruby
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.

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. Malformed source is skipped, as in import-only mode.

Expanded analysis uses a bounded Prism index and adds no runtime dependency. The design spike
considered [RubyDEX](https://github.com/Shopify/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](https://docs.ruby-lang.org/en/master/syntax/modules_and_classes_rdoc.html#label-Constants).

Repositories containing multiple gems are discovered statically: every `*.gemspec` beneath the
project root contributes its adjacent existing `lib` directory to feature resolution. Gemspecs are
Expand All @@ -425,8 +496,9 @@ violations = rule.check(options)
report = ArchUnit.project_graph.with_check_options(options)
~~~

Load-path choices affect graph caching. Equivalent normalized choices reuse a cached graph, while a
different set builds a separate graph. Use `clear_cache: true` after changing files or gemspec
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
Expand Down Expand Up @@ -474,8 +546,8 @@ The implementation conventions and intended dependency directions live in [`AGEN

## 📅 Plans and Current Limitations

- Ruby constants are not modeled as a separate graph. Files are the primary dependency vocabulary.
- Dynamic `require`, `autoload`, and `load` arguments cannot be resolved statically.
- 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.
- The API is still pre-release and may change before the first stable gem version.

Expand Down
34 changes: 34 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ Run the default 1,000-file corpus:
bundle exec ruby benchmark/extraction.rb
```

The corpus includes a declared class and a cross-file constant use per generated source. Compare
import-only extraction against expanded indexing on identical source with:

```bash
bundle exec ruby benchmark/extraction.rb --constants
```

Both runs report cold/warm timing and memory. Expanded runs also report `constant_indexing`,
unresolved/ambiguous-reference counters, and the same-process graph cache reuse result.

Use a smaller CI smoke corpus with deliberately generous, non-flaky limits:

```bash
Expand All @@ -29,6 +39,30 @@ output includes Ruby heap growth on every platform and process peak RSS where `/
regression ceilings; performance decisions should use several local runs of the default corpus or a
non-blocking real-world canary.

## Constant-analysis measurement

On 2026-10-15, Ruby 4.0.6 on Windows x64, three fresh-process runs per mode on the updated
1,000-file / 10-gem corpus produced these medians:

| Measurement | Imports | Imports and constants |
| --- | ---: | ---: |
| Cold graph build | 1.706 s | 1.533 s |
| Warm graph lookup | 1.93 ms | 1.95 ms |
| Constant indexing/resolution | — | 32.0 ms |
| Merged graph edges | 4,001 | 4,001 |

All runs reused the identical cached graph on their warm lookup. The corpus references the same
next file by import and constant, so expanded mode merges 5,001 raw edges into 4,001 edges while
retaining both evidence forms. Every source is parsed once per cold graph. Cold samples ranged from
1.45 to 1.86 seconds across modes. Filesystem and run-order noise prevent deriving a meaningful
overhead percentage from this small sample; the separately measured indexing stage is more useful
here than the difference between total medians.

Heap-growth samples ranged from 3.07–5.49 MB for imports and 6.13–9.90 MB for expanded analysis;
these pre-GC snapshots are sensitive to collection timing and do not establish retained or peak
memory. Windows does not expose the Linux peak-RSS counter. ASTs and indexing structures are
temporary; the cached graph retains immutable edges and evidence only.

## Reference baseline and target

On 2026-08-14, Ruby 4.0.6 on the Windows development machine produced the following default-corpus
Expand Down
12 changes: 10 additions & 2 deletions benchmark/extraction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module ExtractionBenchmark
files: 1_000,
gems: 10,
imports_per_file: 3,
dependency_analysis: :imports,
max_cold_seconds: nil,
max_warm_seconds: nil,
output: nil
Expand Down Expand Up @@ -59,6 +60,7 @@ def write_source(index)
dependencies = dependency_features(index)
contents = dependencies.map { |feature| "require '#{feature}'" }.join("\n")
contents = '# isolated benchmark file' if contents.empty?
contents += "\nclass BenchFile#{index}; BenchFile#{(index + 1) % file_count}.new; end"
write(source_path(index), "#{contents}\n")
end

Expand Down Expand Up @@ -127,13 +129,16 @@ def collect_values

def extract_profiled
profile = ArchUnit::Extraction::ExtractionProfile.new
graph = ArchUnit::Extraction.extract_graph(@root, profile:)
options = ArchUnit::CheckOptions.new(
dependency_analysis: @configuration.fetch(:dependency_analysis)
)
graph = ArchUnit::Extraction.extract_graph(@root, profile:, options:)
[graph, profile]
end

def benchmark_result(values)
{
corpus: @configuration.slice(:files, :gems, :imports_per_file),
corpus: @configuration.slice(:files, :gems, :imports_per_file, :dependency_analysis),
graph_edges: values.cold_graph.length,
graph_cache_reused: values.cold_graph.equal?(values.warm_graph),
cold: values.cold_profile.to_h.merge(memory_delta(values.before, values.after_cold)),
Expand Down Expand Up @@ -195,6 +200,9 @@ def option_parser(options)
configure_corpus_options(parser, options)
configure_threshold_options(parser, options)
parser.on('--output PATH', String) { |value| options[:output] = value }
parser.on('--constants', 'Include project constant references') do
options[:dependency_analysis] = :imports_and_constants
end
end
end

Expand Down
1 change: 1 addition & 0 deletions lib/archunit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ module ArchUnit
Checkable = Common::FluentApi::Checkable
ImportKind = Common::Extraction::ImportKind
Edge = Common::Extraction::Edge
DependencyEvidence = Common::Extraction::DependencyEvidence
Graph = Common::Extraction::Graph
MappedEdge = Common::Projection::MappedEdge
ProjectedEdge = Common::Projection::ProjectedEdge
Expand Down
44 changes: 44 additions & 0 deletions lib/archunit/common/extraction/dependency_evidence.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

require_relative 'import_kind'

module ArchUnit
module Common
module Extraction
# Source-level support for a file dependency, without implying runtime execution.
DependencyEvidence = Data.define(
:source, :kind, :name, :line_number, :end_line, :target_line_number
) do
# Keywords mirror this immutable source span.
def initialize( # rubocop:disable Metrics/ParameterLists
source:, kind:, name:, line_number:, end_line: line_number, target_line_number: nil
)
validate_identity(source, kind, name)
validate_lines(line_number, end_line, target_line_number)
source = source.tr('\\', '/').freeze
name = name.dup.freeze
super
end

private

def validate_identity(source, kind, name)
return if source.is_a?(String) && name.is_a?(String) && ImportKind.valid?(kind)

raise ArgumentError, 'evidence requires a source, name, and valid dependency kind'
end

def validate_lines(first, last, target)
valid = positive_line?(first) && positive_line?(last) && last >= first
return if valid && (target.nil? || positive_line?(target))

raise ArgumentError, 'evidence requires valid source line numbers'
end

def positive_line?(value)
value.is_a?(Integer) && value.positive?
end
end
end
end
end
9 changes: 7 additions & 2 deletions lib/archunit/common/extraction/edge.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
# frozen_string_literal: true

require_relative 'import_kind'
require_relative 'dependency_evidence'

module ArchUnit
module Common
module Extraction
Edge = Data.define(:source, :target, :external, :import_kinds) do
def initialize(source:, target:, external:, import_kinds: [])
Edge = Data.define(:source, :target, :external, :import_kinds, :evidence) do
def initialize(source:, target:, external:, import_kinds: [], evidence: [])
source = immutable_identifier(source, :source)
target = immutable_identifier(target, :target)
validate_external(external)
import_kinds = immutable_import_kinds(import_kinds)
evidence = Array(evidence).uniq.freeze
unless evidence.all?(DependencyEvidence)
raise ArgumentError, 'evidence must contain DependencyEvidence values'
end

super
end
Expand Down
3 changes: 2 additions & 1 deletion lib/archunit/common/extraction/import_kind.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ module ImportKind
REQUIRE_RELATIVE = :require_relative
AUTOLOAD = :autoload
LOAD = :load
CONSTANT_REFERENCE = :constant_reference

ALL = [REQUIRE, REQUIRE_RELATIVE, AUTOLOAD, LOAD].freeze
ALL = [REQUIRE, REQUIRE_RELATIVE, AUTOLOAD, LOAD, CONSTANT_REFERENCE].freeze

def self.valid?(value)
ALL.include?(value)
Expand Down
Loading
Loading