diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191511a..aaa39b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 957d1ee..4fc42b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/Gemfile.lock b/Gemfile.lock index 5df65cd..a73b216 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 diff --git a/README.md b/README.md index a7a220e..20dafca 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/lib/archunit/common/fluentapi/checkable.rb b/lib/archunit/common/fluentapi/checkable.rb index b359d56..39670cb 100644 --- a/lib/archunit/common/fluentapi/checkable.rb +++ b/lib/archunit/common/fluentapi/checkable.rb @@ -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 @@ -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 diff --git a/lib/archunit/common/logging/check_logger.rb b/lib/archunit/common/logging/check_logger.rb index 18ffee8..156fb79 100644 --- a/lib/archunit/common/logging/check_logger.rb +++ b/lib/archunit/common/logging/check_logger.rb @@ -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 diff --git a/lib/archunit/common/logging/inspection.rb b/lib/archunit/common/logging/inspection.rb new file mode 100644 index 0000000..e5d91cd --- /dev/null +++ b/lib/archunit/common/logging/inspection.rb @@ -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 diff --git a/lib/archunit/extraction/extract_graph.rb b/lib/archunit/extraction/extract_graph.rb index 4c894ba..b4390a9 100644 --- a/lib/archunit/extraction/extract_graph.rb +++ b/lib/archunit/extraction/extract_graph.rb @@ -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' @@ -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 diff --git a/lib/archunit/files/fluentapi/file_rule_support.rb b/lib/archunit/files/fluentapi/file_rule_support.rb index 2eaf693..a52ca6a 100644 --- a/lib/archunit/files/fluentapi/file_rule_support.rb +++ b/lib/archunit/files/fluentapi/file_rule_support.rb @@ -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 @@ -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 diff --git a/lib/archunit/layers/fluentapi/layered_architecture.rb b/lib/archunit/layers/fluentapi/layered_architecture.rb index 8e734ef..b316b60 100644 --- a/lib/archunit/layers/fluentapi/layered_architecture.rb +++ b/lib/archunit/layers/fluentapi/layered_architecture.rb @@ -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( diff --git a/lib/archunit/metrics/fluentapi/custom_metric_condition.rb b/lib/archunit/metrics/fluentapi/custom_metric_condition.rb index 74a0f97..01c7781 100644 --- a/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +++ b/lib/archunit/metrics/fluentapi/custom_metric_condition.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative '../../common/fluentapi/checkable' +require_relative 'logged_metric' require_relative '../assertion/custom_metric' module ArchUnit @@ -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 diff --git a/lib/archunit/metrics/fluentapi/logged_metric.rb b/lib/archunit/metrics/fluentapi/logged_metric.rb new file mode 100644 index 0000000..1ab6e06 --- /dev/null +++ b/lib/archunit/metrics/fluentapi/logged_metric.rb @@ -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 diff --git a/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb b/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb index f35e93c..223f776 100644 --- a/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +++ b/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative '../../common/fluentapi/checkable' +require_relative 'logged_metric' require_relative '../assertion/metric_predicate' module ArchUnit @@ -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 diff --git a/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb b/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb index 398f8d8..e2d9d65 100644 --- a/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +++ b/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative '../../common/fluentapi/checkable' +require_relative 'logged_metric' require_relative '../assertion/metric_threshold' module ArchUnit @@ -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 diff --git a/lib/archunit/metrics/fluentapi/metrics_builder.rb b/lib/archunit/metrics/fluentapi/metrics_builder.rb index cdf985f..2f6cb32 100644 --- a/lib/archunit/metrics/fluentapi/metrics_builder.rb +++ b/lib/archunit/metrics/fluentapi/metrics_builder.rb @@ -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 diff --git a/lib/archunit/slices/fluentapi/diagram_slice_condition.rb b/lib/archunit/slices/fluentapi/diagram_slice_condition.rb index c8594bd..abf056f 100644 --- a/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +++ b/lib/archunit/slices/fluentapi/diagram_slice_condition.rb @@ -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 diff --git a/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb b/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb index 08d81e3..4f9ed6b 100644 --- a/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +++ b/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb @@ -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 ) diff --git a/lib/archunit/version.rb b/lib/archunit/version.rb index 0a6c764..d3437d3 100644 --- a/lib/archunit/version.rb +++ b/lib/archunit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ArchUnit - VERSION = '0.0.1' + VERSION = '0.0.2' end diff --git a/spec/common/logging/inspection_spec.rb b/spec/common/logging/inspection_spec.rb new file mode 100644 index 0000000..20846b4 --- /dev/null +++ b/spec/common/logging/inspection_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'stringio' +require 'tmpdir' + +RSpec.describe ArchUnit::Common::Logging::Inspection do + def logger(output, level: :debug) + ArchUnit::CheckLogger.new(ArchUnit::LoggingOptions.new(io: output, level:)) + end + + it 'restores nested contexts, including exceptions, and does not evaluate disabled details' do + output = StringIO.new + outer = logger(output) + expect(described_class.logger).to be_nil + described_class.debug { raise 'disabled' } + described_class.with(outer) do + expect do + described_class.with(logger(StringIO.new, level: :info)) do + described_class.debug { raise 'disabled' } + raise 'check failed' + end + end.to raise_error('check failed') + expect(described_class.logger).to equal(outer) + described_class.debug { 'outer restored' } + end + expect(described_class.logger).to be_nil + expect(output.string).to include('outer restored') + end + + it 'isolates suspended fibers and threads from a running check' do + first = StringIO.new + second = StringIO.new + fiber = Fiber.new do + described_class.with(logger(first)) do + Fiber.yield + described_class.debug { 'first only' } + end + end + fiber.resume + described_class.with(logger(second)) do + expect(Thread.new { described_class.logger }.value).to be_nil + described_class.debug { 'second only' } + fiber.resume + end + expect(first.string).to include('first only') + expect(first.string).not_to include('second only') + expect(second.string).to include('second only') + expect(second.string).not_to include('first only') + end + + it 'inspects a cached graph and selectors without changing rule results' do + Dir.mktmpdir('archunit-inspection') do |root| + File.write(File.join(root, 'Gemfile'), '') + File.write(File.join(root, 'service.rb'), "require 'json'\nclass Service; end\n") + rule = ArchUnit.project_files(root).should.have_name('*.rb') + baseline = rule.check + output = StringIO.new + options = ArchUnit::CheckOptions.new(logging: ArchUnit::LoggingOptions.new( + io: output, level: :debug + )) + expect(rule.check(options)).to eq(baseline) + expect(output.string).to include( + 'project root:', 'discovered file: "service.rb"', + 'dependency: "service.rb" -> "json"', 'external=true', + 'selected file: "service.rb"', 'selected file count: 1', 'graph edges: 2' + ) + ensure + ArchUnit.clear_graph_cache + end + end + + it 'logs passing custom metric values once without repeating user callbacks' do + Dir.mktmpdir('archunit-metric-inspection') do |root| + File.write(File.join(root, 'Gemfile'), '') + File.write(File.join(root, 'service.rb'), "class Service; def call; end; end\n") + calls = [] + calculation = lambda do |subject| + calls << subject.identifier + 7 + end + rule = ArchUnit.metrics(root).custom_metric('score', 'fixture score', calculation) + .should_satisfy(->(value, _subject) { value == 7 }) + output = StringIO.new + options = ArchUnit::CheckOptions.new(logging: ArchUnit::LoggingOptions.new( + io: output, level: :debug + )) + expect(rule.check(options)).to be_empty + expect(calls.length).to eq(1) + expect(output.string).to include('metric file: "service.rb"', 'log metric: score=7') + calls.clear + expect(rule.check).to be_empty + expect(calls.length).to eq(1) + end + end +end