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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,19 @@ jobs:
ruby-version: "4.0"
bundler-cache: true
working-directory: ArchUnitRuby-TestRepo-RAG
env:
# The fixture locks an older version of the adjacent, checked-out path gem.
# Refresh that entry for this revision while retaining its locked dependencies.
BUNDLE_FROZEN: "false"

- name: Verify the fixture loads this checkout
working-directory: ArchUnitRuby-TestRepo-RAG
run: >-
bundle exec ruby -e
"require 'archunit'; expected = File.realpath('../ArchUnitRuby');
actual = File.realpath(Gem.loaded_specs.fetch('archunit').full_gem_path);
abort 'Fixture loaded a different ArchUnit checkout' unless actual == expected;
puts \"Testing ArchUnit #{ArchUnit::VERSION} from #{actual}\""

- name: Test the RAG fixture against this revision
run: bundle exec rake
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog

## Unreleased
## 0.0.2 - 2026-09-30

- Add opt-in debug inspection of project roots, graphs, and selected files on cold and cached checks.
- Log passing metric values without repeating custom calculations.
- Document verbosity, CI log artifacts, and existing colored violation reports.

- Discover adjacent `lib` directories in multi-gemspec repositories without evaluating gemspecs.
- Support validated, project-local custom load paths through `CheckOptions`.
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
archunit (0.0.1)
archunit (0.0.2)
csv (>= 3.3, < 4.0)
json (>= 2.7, < 3.0)
prism (>= 1.0, < 2.0)
Expand Down Expand Up @@ -83,7 +83,7 @@ DEPENDENCIES
yard (~> 0.9.45)

CHECKSUMS
archunit (0.0.1)
archunit (0.0.2)
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ ArchUnit.project_files
```

It is a working executable prototype with file, layer, slice, graph-reporting, and metric APIs. It
is tested on Ruby 3.3, 3.4, and 4.0 on Linux and Ruby 4.0 on Windows. Version 0.0.1 is available as
is tested on Ruby 3.3, 3.4, and 4.0 on Linux and Ruby 4.0 on Windows. Install the latest release as
[`archunit`](https://rubygems.org/gems/archunit) on RubyGems.

Siblings: [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS) and
Expand Down Expand Up @@ -337,6 +337,45 @@ Levels are `debug`, `info`, `warn`, and `error`. The fixed events cover check st
violations, and metric evidence. `io:` defaults to `$stderr`, accepts any writable stream, and may be
`nil`. File output creates missing directories and writes timestamped `archunit-*.log` files.

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

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

Example debug lines (timestamp prefixes omitted):

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

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

### Readable, colored failure reports

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

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

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

## 🕵️ Technical Deep Dive

### What ArchUnitRuby Extracts
Expand Down
5 changes: 4 additions & 1 deletion lib/archunit/common/fluentapi/checkable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require_relative '../assertion/violation'
require_relative '../assertion/empty_test_violation'
require_relative '../logging/check_logger'
require_relative '../logging/inspection'
require_relative 'check_options'

module ArchUnit
Expand All @@ -28,7 +29,9 @@ def perform_check(_options)

def execute_check(options, logger, check_name)
logger.log_progress("executing #{check_name}")
violations = validate_violations(perform_check(options))
violations = Logging::Inspection.with(logger) do
validate_violations(perform_check(options))
end
log_violations(logger, violations)
logger.end_check(check_name, violation_count: violations.length)
violations
Expand Down
8 changes: 8 additions & 0 deletions lib/archunit/common/logging/check_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ def log_metric(name:, value:, subject: nil)
write(:debug, "log metric: #{metric}=#{value}#{context}")
end

def debug?
!@options.nil? && enabled?(:debug)
end

def debug
write(:debug, "inspect: #{yield}") if debug?
end

def close
@mutex.synchronize do
@file&.close
Expand Down
59 changes: 59 additions & 0 deletions lib/archunit/common/logging/inspection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module ArchUnit
module Common
module Logging
# Internal inspection context, scoped to one check and isolated by Ruby fiber.
module Inspection
module_function

def with(logger)
previous = Thread.current[:archunit_inspection_logger]
Thread.current[:archunit_inspection_logger] = logger
yield
ensure
Thread.current[:archunit_inspection_logger] = previous
end

def logger
Thread.current[:archunit_inspection_logger]
end

def debug(&)
logger&.debug(&)
end

def graph(graph, root:)
debug do
logger.debug { "project root: #{root.to_s.inspect}" }
graph.each { |edge| logger.debug { describe_edge(edge) } }
"graph edges: #{graph.size}"
end
graph
end

def selection(label, items)
debug do
items.each { |item| logger.debug { "#{label}: #{yield(item).inspect}" } }
"#{label} count: #{items.length}"
end
items
end

def projection(edges)
selection('projected dependency', edges) do |edge|
"#{edge.source_label} -> #{edge.target_label} (#{edge.cumulated_edges.length} edges)"
end
end

def describe_edge(edge)
return "discovered file: #{edge.source.inspect}" if edge.source == edge.target

"dependency: #{edge.source.inspect} -> #{edge.target.inspect} " \
"(external=#{edge.external}, kinds=#{edge.import_kinds.inspect})"
end
private_class_method :describe_edge
end
end
end
end
5 changes: 3 additions & 2 deletions lib/archunit/extraction/extract_graph.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require_relative '../common/extraction/edge'
require_relative '../common/extraction/graph'
require_relative '../common/fluentapi/check_options'
require_relative '../common/logging/inspection'
require_relative 'enumerate_source_files'
require_relative 'extract_dependencies'
require_relative 'extraction_profile'
Expand All @@ -29,9 +30,9 @@ def extract_graph(
Pathname.new(locate_project(locator, working_directory:))
end
patterns = resolve_exclude_patterns(exclude_patterns)
cache_key = graph_cache_key(root, exclude_patterns: patterns, options:)
key = graph_cache_key(root, exclude_patterns: patterns, options:)

fetch_graph(root, patterns, options, cache_key, profile)
Common::Logging::Inspection.graph(fetch_graph(root, patterns, options, key, profile), root:)
end
end

Expand Down
9 changes: 8 additions & 1 deletion lib/archunit/files/fluentapi/file_rule_support.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative '../../common/pattern_matching'
require_relative '../../common/logging/inspection'
require_relative '../../common/projection/project_to_nodes'

module ArchUnit
Expand All @@ -12,12 +13,18 @@ module FileRuleSupport

def selected_nodes(graph, filters)
nodes = Common::Projection.project_to_nodes(graph)
return nodes if filters.empty?
selected = filters.empty? ? nodes : matching_nodes(nodes, filters)
Common::Logging::Inspection.selection(
'selected file', selected, &:label
)
end

def matching_nodes(nodes, filters)
nodes.select do |node|
Common::PatternMatching.matches_all_patterns?(node.label, filters)
end
end
private_class_method :matching_nodes
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/archunit/layers/fluentapi/layered_architecture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def perform_check(options)
graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
nodes = Common::Projection.project_to_nodes(graph)
edges = Common::Projection.project_edges(graph, Common::Projection.per_internal_edge)
Common::Logging::Inspection.projection(edges)

empty_policy_violations(nodes, options) +
Assertion.gather_layer_dependency_violations(
Expand Down
5 changes: 4 additions & 1 deletion lib/archunit/metrics/fluentapi/custom_metric_condition.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative '../../common/fluentapi/checkable'
require_relative 'logged_metric'
require_relative '../assertion/custom_metric'

module ArchUnit
Expand Down Expand Up @@ -32,7 +33,9 @@ def perform_check(options)
)
return empty_test if empty_test

Assertion.gather_custom_metric_violations(classes, selection.metric, predicate)
Assertion.gather_custom_metric_violations(
classes, LoggedMetric.wrap(selection.metric), predicate
)
end
end
end
Expand Down
23 changes: 23 additions & 0 deletions lib/archunit/metrics/fluentapi/logged_metric.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

require_relative '../../common/logging/inspection'

module ArchUnit
module Metrics
module FluentApi
# Observes the existing calculation once, including values that pass the rule.
module LoggedMetric
def self.wrap(metric)
logger = Common::Logging::Inspection.logger
return metric unless logger&.debug?

metric.with(calculation: lambda do |subject|
value = metric.calculate(subject)
logger.log_metric(name: metric.name, value:, subject: subject.identifier)
value
end)
end
end
end
end
end
5 changes: 4 additions & 1 deletion lib/archunit/metrics/fluentapi/metric_predicate_condition.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative '../../common/fluentapi/checkable'
require_relative 'logged_metric'
require_relative '../assertion/metric_predicate'

module ArchUnit
Expand Down Expand Up @@ -34,7 +35,9 @@ def perform_check(options)
)
return empty_test if empty_test

Assertion.gather_metric_predicate_violations(subjects, selection.metric, predicate)
Assertion.gather_metric_predicate_violations(
subjects, LoggedMetric.wrap(selection.metric), predicate
)
end
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/archunit/metrics/fluentapi/metric_threshold_condition.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative '../../common/fluentapi/checkable'
require_relative 'logged_metric'
require_relative '../assertion/metric_threshold'

module ArchUnit
Expand Down Expand Up @@ -38,7 +39,7 @@ def perform_check(options)
return empty_test if empty_test

Assertion.gather_metric_threshold_violations(
subjects, selection.metric, comparison, threshold
subjects, LoggedMetric.wrap(selection.metric), comparison, threshold
)
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/archunit/metrics/fluentapi/metrics_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def custom_metric(name, description, calculation)
def analyze
project = Extraction.extract_project_info(project_locator)
selected_files = project.files.filter_map { |file| selected_file_info(file) }
Common::Logging::Inspection.selection('metric file', selected_files, &:path)
Extraction::ProjectInfo.new(project_root: project.project_root, files: selected_files)
end

Expand Down
1 change: 1 addition & 0 deletions lib/archunit/slices/fluentapi/diagram_slice_condition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def perform_check(check_options)

diagram = Uml::PlantUmlParser.parse(diagram_source.read)
edges = Common::Projection.project_edges(graph, projection)
Common::Logging::Inspection.projection(edges)
Assertion.gather_diagram_adherence_violations(edges, diagram, options)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def perform_check(options)
return empty_test if empty_test

edges = Common::Projection.project_edges(graph, projection)
Common::Logging::Inspection.projection(edges)
Assertion.gather_forbidden_slice_dependency_violations(
edges, source_slice, target_slice
)
Expand Down
2 changes: 1 addition & 1 deletion lib/archunit/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module ArchUnit
VERSION = '0.0.1'
VERSION = '0.0.2'
end
Loading
Loading