diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ae395c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +name: Bug report +description: Report reproducible incorrect behavior in ArchUnitRuby +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: >- + Please remove credentials, private source code, and customer data. Security issues must use + private vulnerability reporting instead. + - type: input + id: versions + attributes: + label: Versions + description: ArchUnitRuby version, Ruby version, operating system, and test framework + placeholder: archunit 0.0.1, Ruby 3.4, Ubuntu, RSpec 3.13 + validations: + required: true + - type: textarea + id: rule + attributes: + label: Minimal rule and project layout + description: Show the smallest synthetic rule, files, and dependencies that reproduce the problem. + render: ruby + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include the violation output or stack trace after removing sensitive information. + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5ed9d1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: false +contact_links: + - name: Support guide + url: https://github.com/LukasNiessen/ArchUnitRuby/blob/main/SUPPORT.md + about: Read how to ask a useful usage question. + - name: Private security report + url: https://github.com/LukasNiessen/ArchUnitRuby/security/advisories/new + about: Report vulnerabilities privately instead of opening a public issue. + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..af7f182 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: Feature request +description: Propose a new architecture rule or improvement +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Architectural problem + description: What architecture policy is difficult or impossible to express today? + validations: + required: true + - type: textarea + id: sentence + attributes: + label: Proposed fluent sentence + description: Write how the complete rule should read in Ruby. + render: ruby + validations: + required: true + - type: textarea + id: siblings + attributes: + label: ArchUnit family comparison + description: If known, describe how ArchUnitTS, ArchUnitPython, or another ArchUnit implements it. + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..30a972a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +Describe the user-facing problem and the chosen solution. + +## ArchUnit family alignment + +Explain how this matches the shared ArchUnit fluent API, or why idiomatic Ruby requires a deliberate +difference. + +## Verification + +- [ ] Added or updated unit tests +- [ ] Added or updated an end-to-end fluent API test +- [ ] `bundle exec rake` passes +- [ ] `COVERAGE=true bundle exec rspec` passes +- [ ] `bundle exec rake docs` passes for documentation or public API changes +- [ ] Updated README, API guide, and changelog where relevant +- [ ] No credentials, private source code, or customer data are included diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaa39b5..21f9d71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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' diff --git a/.yardopts b/.yardopts index 6bdc123..9dfffe7 100644 --- a/.yardopts +++ b/.yardopts @@ -9,3 +9,4 @@ lib/**/*.rb - README.md +API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..12c77a3 --- /dev/null +++ b/API.md @@ -0,0 +1,236 @@ +# ArchUnitRuby API Guide + +This guide is the stable map of ArchUnitRuby's public fluent API. The +[class and method reference](class_list.html) is generated from the source, while this page explains +which entry point to choose and how the stages fit together. + +All rule builders are immutable and lazy. Constructing a rule does not read the project; `check`, +`measure`, `snapshot`, and export methods are the terminals that perform work. + +## Common rule shape + +~~~ruby +rule = ArchUnit.project_files + .in_folder('app/api/**') + .should_not.depend_on_files + .in_folder('app/database/**') + +violations = rule.check +~~~ + +| Stage | Public vocabulary | Result | +| --- | --- | --- | +| Entry | `project_files`, `project_layers`, `project_slices`, `project_graph`, `metrics` | Immutable builder | +| Scope | `with_name`, `in_folder`, `in_path`, `in_file`, `for_classes_matching` | Narrower builder | +| Mood | `should`, `should_not` | Positive or negated predicate builder | +| Predicate | File, layer, slice, or metric policy | Checkable rule | +| Terminal | `check`, `measure`, `snapshot`, `to_*`, `export_as_*` | Data or an artifact | + +String patterns are anchored globs. `*` stays inside one path segment, `**` crosses zero or more +segments, and `?` matches one non-separator character. A trailing folder glob such as +`app/api/**` includes the `app/api` folder itself and every descendant folder. Regular expressions +are accepted by all pattern selectors except the exact `in_file` selector. + +Every pattern selector accepts `except:`. Pass a pattern, an array of patterns, or explicit selector +keys such as `{ in_folder: 'generated', with_name: '*_spec.rb' }`. + +## File rules + +Start with `ArchUnit.project_files(project_locator = nil)`; `ArchUnit.files` is its short alias. +Without a locator, ArchUnitRuby searches upward for a `Gemfile` or gemspec. + +| Scope or predicate | Purpose | +| --- | --- | +| `with_name(pattern)` | Select or require a filename pattern | +| `in_folder(pattern)` | Select or require a project-relative directory | +| `in_path(pattern)` | Select or require a complete project-relative path | +| `in_file(path)` | Select one exact project-relative file | +| `have_no_cycles` | Reject cycles among selected project files | +| `depend_on_files` | Allow or reject dependencies matching target selectors | +| `depend_on_external_modules` | Allow or reject standard-library or gem imports | +| `adhere_to(callable, message)` | Evaluate a custom predicate over immutable `FileInfo` values | + +~~~ruby +service_scope = ArchUnit.project_files.in_folder('app/services/**') + +rules = [ + service_scope.should.have_name('*_service.rb'), + service_scope.should_not.depend_on_files.in_folder('app/controllers/**'), + service_scope.should_not.depend_on_external_modules.matching('net/http'), + service_scope.should.adhere_to( + ->(file) { file.lines_of_code < 300 }, + 'services must stay below 300 non-blank lines' + ) +] +~~~ + +Positive dependency rules are allowlists: every selected dependency must match at least one target. +Negated dependency rules are blocklists: matching dependencies become violations. + +## Layer rules + +Use `ArchUnit.project_layers(project_locator = nil)` to name architectural layers and then declare +their allowed or forbidden relationships. + +~~~ruby +rule = ArchUnit.project_layers + .layer('api').defined_by('app/api/**/*.rb') + .layer('services').defined_by_folder('app/services/**') + .layer('database').defined_by('app/database/**/*.rb') + .where_layer('api').may_only_depend_on_layers('services') + .where_layer('services').may_only_depend_on_layers('database') + .where_layer('database').may_only_depend_on_layers +~~~ + +`may_only_depend_on_layers` creates an allowlist. Calling it without targets seals the layer. +`may_not_depend_on_layers` creates a blocklist and requires at least one target. Dependencies inside +one layer are allowed; edges with an unassigned endpoint are ignored. + +## Slices and diagrams + +Use `ArchUnit.project_slices(project_locator = nil)` to capture one segment from project paths. +`(**)` is the glob capture; `defined_by_regex` uses the first regular-expression capture. + +~~~ruby +slices = ArchUnit.project_slices.defined_by('lib/my_app/(**)/') + +dependency_rule = slices.should_not.contain_dependency('api', 'database') +diagram_rule = slices.should + .ignoring_external_slices + .ignoring_orphan_slices + .adhere_to_diagram_in_file('docs/architecture.puml') + +plantuml = slices.to_plantuml +slices.export_as_plantuml('reports/architecture.puml') +~~~ + +`adhere_to_diagram` accepts inline PlantUML. The supported subset includes components, directed +dependencies, comments, `@startuml`, and `@enduml`. + +## Dependency graph reports + +Use `ArchUnit.project_graph(project_locator = nil)` for queryable snapshots and reports. + +~~~ruby +report = ArchUnit.project_graph + .include_external_dependencies + .focus_on('app/services/**', 2) + .collapse_to_folder_depth(2) + .titled('Service dependencies') + +summary = report.summary +snapshot = report.snapshot +report.export_as_mermaid('reports/services.mmd') +report.export_as_html('reports/services.html') +~~~ + +Query modifiers are `focus_on(pattern, depth)`, `reachable_from(pattern)`, and +`dependents_of(pattern)`. Group nodes with `collapse_to_folder_depth` or +`collapse_by_pattern(pattern, replacement)`. Formats are DOT, Mermaid, D2, CSV, JSON, and +self-contained HTML; each provides both `to_` and `export_as_`. + +## Metrics + +Start with `ArchUnit.metrics(project_locator = nil)`. Select files with the standard file selectors +and classes with `for_classes_matching`. + +~~~ruby +services = ArchUnit.metrics + .in_path('app/services/**/*.rb') + .for_classes_matching('*Service') + +rules = [ + services.count.method_count.should_be_below_or_equal(20), + services.lcom.lcom4.should_be(1), + services.distance.instability.should_be_below(0.8) +] + +measurements = services.count.method_count.measure +services.count.export_as_html('reports/service-counts') +~~~ + +Count metrics cover methods, fields, lines, statements, imports, classes, and top-level functions. +Cohesion provides LCOM96a, LCOM96b, LCOM1-5, and LCOM*. Distance metrics include abstractness, +instability, main-sequence distance, normalized distance, and coupling factor. + +Thresholds use exactly `should_be_below`, `should_be_above`, `should_be`, +`should_be_below_or_equal`, `should_be_above_or_equal`, and `should_satisfy`. Use `custom_metric` +for a calculation over `ClassInfo`; zone guards are `not_in_zone_of_pain` and +`not_in_zone_of_uselessness`. + +## Checking and test frameworks + +Every architecture rule includes `ArchUnit::Checkable` and exposes `check(options = nil)`. It returns +an array of structured `Violation` values; an architecture disagreement is not an exception until +an assertion adapter translates it at the test boundary. + +~~~ruby +violations = rule.check + +expect(rule).to pass # RSpec +assert_passes(rule) # Minitest +ArchUnit.assert_passes(rule) # Framework-neutral +~~~ + +Use `ArchUnit.format_violations` or `ArchUnit::ResultFactory` when integrating with another test +framework or command-line interface. + +## Check options and logging + +`ArchUnit::CheckOptions` keeps optional execution behavior out of the fluent sentence: + +~~~ruby +logging = ArchUnit::LoggingOptions.new( + level: :debug, + output_directory: 'tmp/archunit-logs' +) + +options = ArchUnit::CheckOptions.new( + allow_empty_tests: false, + clear_cache: false, + load_paths: ['components/billing/lib'], + dependency_analysis: :imports, + logging: logging +) + +violations = rule.check(options) +~~~ + +Zero selected subjects produce `EmptyTestViolation` by default. Set `allow_empty_tests: true` only +when an empty scope is intentionally valid. Set `clear_cache: true` after modifying the analyzed +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 +raise `ArchUnit::TechnicalError`. Rule failures remain violation data. + +See the [main guide](index.html) for installation and complete examples, [SUPPORT.md](https://github.com/LukasNiessen/ArchUnitRuby/blob/main/SUPPORT.md) +for help, and [SECURITY.md](https://github.com/LukasNiessen/ArchUnitRuby/blob/main/SECURITY.md) for +responsible vulnerability reporting. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc42b8..6c74829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,21 @@ # Changelog -## 0.0.2 - 2026-09-30 +## 0.0.2 (unreleased) - 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. +- 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 + documentation quality checks. +- Add the ArchUnitRuby family logo, social preview, and repository community health files. - Discover adjacent `lib` directories in multi-gemspec repositories without evaluating gemspecs. - Support validated, project-local custom load paths through `CheckOptions`. - Add cold/warm extraction profiling, a reproducible benchmark, and resolution caching. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2ffa6f0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,40 @@ +# Contributor Covenant Code of Conduct + +## Our pledge + +We pledge to make participation in this project a harassment-free experience for everyone, +regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, +gender identity and expression, level of experience, education, socioeconomic status, nationality, +personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, +and healthy community. + +## Our standards + +Examples of behavior that contributes to a positive environment include demonstrating empathy, +respecting differing viewpoints, giving and accepting constructive feedback, taking responsibility +for mistakes, and focusing on what is best for the community. + +Unacceptable behavior includes sexualized language or attention, trolling or insulting comments, +harassment, publishing another person's private information without permission, and other conduct +that could reasonably be considered inappropriate in a professional setting. + +## Enforcement responsibilities + +Project maintainers may clarify and enforce these standards and may remove, edit, or reject +comments, commits, code, issues, and other contributions that do not align with this Code of +Conduct. Moderation decisions should explain the reason when appropriate. + +## Scope and reporting + +This Code applies in project spaces and when someone officially represents the project. Report +abusive or harassing GitHub content through GitHub's reporting tools and contact a maintainer +privately through the contact options on their GitHub profile. Reports will be reviewed promptly and +handled with appropriate confidentiality. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..919e004 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to ArchUnitRuby + +Thank you for helping improve the Ruby member of the ArchUnitEverything family. Small, focused +changes with tests are easiest to review. + +## Before opening a change + +- Search the [existing issues](https://github.com/LukasNiessen/ArchUnitRuby/issues). +- Use an issue for behavior changes or larger additions so the Ruby API can stay aligned with the + sibling implementations. +- Read [AGENTS.md](AGENTS.md) for architecture, naming, and fluent-API conventions. +- Never include credentials, private project source, or customer data in an issue or fixture. + +## Development setup + +ArchUnitRuby requires Ruby 3.3 or newer. + +~~~bash +git clone https://github.com/LukasNiessen/ArchUnitRuby.git +cd ArchUnitRuby +bundle install +bundle exec rake +~~~ + +`bundle exec rake` runs the randomized RSpec suite and RuboCop. Before submitting documentation or +public API changes, also run: + +~~~bash +COVERAGE=true bundle exec rspec +bundle exec rake docs +gem build archunit.gemspec --strict +~~~ + +The coverage suite enforces at least 98% line and 90% branch coverage. The documentation task builds +the complete site, checks the public API guide, rejects unrendered Markdown, and validates internal +links. + +## Design expectations + +- Write the fluent sentence first and read it aloud. +- Prefer idiomatic Ruby when a sibling convention conflicts with the language. +- Keep builders immutable and lazy; only terminals perform analysis. +- Return structured violations for architecture disagreements—do not raise until the assertion + boundary. +- Add a focused unit test and an end-to-end fluent API test for behavior changes. +- Keep `# frozen_string_literal: true` in Ruby files and leave RuboCop clean. +- Update `README.md`, `API.md`, and `CHANGELOG.md` when public behavior changes. + +## Pull requests + +A useful pull request explains the user-facing problem, the chosen behavior, any deliberate +cross-language difference, and how it was tested. Please keep unrelated formatting or refactors out +of the same change. + +By participating, you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). Security reports +must follow [SECURITY.md](SECURITY.md), not a public issue. + diff --git a/Gemfile b/Gemfile index 05d39dd..9690491 100644 --- a/Gemfile +++ b/Gemfile @@ -4,10 +4,11 @@ source 'https://rubygems.org' gemspec +gem 'irb', '~> 1.15' gem 'kramdown', '~> 2.5.2' gem 'minitest', '~> 5.25' gem 'rake', '~> 13.2' gem 'rspec', '~> 3.13' -gem 'rubocop', '~> 1.80' +gem 'rubocop', '~> 1.92' gem 'simplecov', '~> 0.22', require: false gem 'yard', '~> 0.9.45' diff --git a/Gemfile.lock b/Gemfile.lock index a73b216..96a35f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,21 +13,43 @@ GEM csv (3.3.6) diff-lcs (1.6.2) docile (1.4.1) + erb (6.0.7) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) json (2.21.2) kramdown (2.5.2) rexml (>= 3.4.4) language_server-protocol (3.17.0.6) lint_roller (1.1.0) + logger (1.7.0) minitest (5.27.0) - parallel (2.1.0) + parallel (2.3.0) parser (3.3.12.0) ast (~> 2.4.1) racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) prism (1.9.0) racc (1.8.1) rainbow (3.1.1) rake (13.4.2) - regexp_parser (2.12.0) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.13.1) + reline (0.7.0) + io-console (~> 0.5) rexml (3.4.4) rspec (3.13.2) rspec-core (~> 3.13.0) @@ -42,14 +64,14 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) - rubocop (1.89.0) - json (~> 2.3) + rubocop (1.92.0) + json (>= 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) + regexp_parser (>= 2.11.3, < 3.0) rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) @@ -63,9 +85,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.2.0) + tsort (0.2.0) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) yard (0.9.45) PLATFORMS @@ -74,11 +97,12 @@ PLATFORMS DEPENDENCIES archunit! + irb (~> 1.15) kramdown (~> 2.5.2) minitest (~> 5.25) rake (~> 13.2) rspec (~> 3.13) - rubocop (~> 1.80) + rubocop (~> 1.92) simplecov (~> 0.22) yard (~> 0.9.45) @@ -88,32 +112,42 @@ CHECKSUMS csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 - parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parallel (2.3.0) sha256=f75a3e904101ce6a1ccb6b8dc800cbafd42166d83330192438dcf29395167a6f parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c - rubocop (1.89.0) sha256=4dee8e3ee9c45e474834efd9e8d6fd031e8331c8dacdff0de4ad65ae0a6faae7 + rubocop (1.92.0) sha256=2af68041fb2e70bd9321d0479b49fad07f8008a3d0758c83347b4917128c56e0 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 - unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 - unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613 + unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce yard (0.9.45) sha256=52e211493f7cb8a3ebf7e104a25a1e73937a3103092545d34cb88fafebb3dc51 BUNDLED WITH diff --git a/README.md b/README.md index b1fb355..a61bf9c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # ArchUnitRuby - Architecture Testing -
- -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Build & tests](https://img.shields.io/github/actions/workflow/status/LukasNiessen/ArchUnitRuby/ci.yml?branch=main&label=build%20%26%20tests)](https://github.com/LukasNiessen/ArchUnitRuby/actions/workflows/ci.yml) [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitRuby.svg)](https://github.com/LukasNiessen/ArchUnitRuby)
-[![Gem downloads](https://img.shields.io/gem/dt/archunit.svg)](https://clickgems.clickhouse.com/dashboard/archunit) [![Ruby 3.3+](https://img.shields.io/badge/Ruby-3.3%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/) - -
+

+ ArchUnitRuby logo +

+ +

+ License: MIT + Build & tests + GitHub stars
+ Gem downloads + Ruby 3.3+ +

Enforce architecture rules in Ruby projects. Check dependency directions, detect circular dependencies, enforce naming and location conventions, measure code quality, and generate @@ -13,7 +18,7 @@ architecture reports as ordinary Ruby tests. _Inspired by the amazing ArchUnit library, but not affiliated with ArchUnit._ -[Setup](#-setup) · [Use Cases](#-use-cases) · [Features](#-features) · [Documentation](https://lukasniessen.github.io/ArchUnitRuby/) · [Sponsor](https://github.com/sponsors/LukasNiessen) · [Contributing](#-contributing) +[Setup](#-setup) · [Use Cases](#-use-cases) · [Features](#-features) · [API Guide](https://lukasniessen.github.io/ArchUnitRuby/file.API.html) · [Documentation](https://lukasniessen.github.io/ArchUnitRuby/) · [Contributing](CONTRIBUTING.md) ArchUnitRuby turns a Ruby codebase into a dependency graph and lets you test that graph with rules that read like English: @@ -29,12 +34,14 @@ It is a working executable prototype with file, layer, slice, graph-reporting, a 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 -[ArchUnitPython](https://github.com/LukasNiessen/ArchUnitPython). +ArchUnitRuby follows the shared ArchUnitEverything architecture and fluent-API standard used by +its siblings [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS) and +[ArchUnitPython](https://github.com/LukasNiessen/ArchUnitPython), adapted where Ruby idioms differ. -The [documentation site](https://lukasniessen.github.io/ArchUnitRuby/) combines this guide with a -searchable, source-generated API reference for every public module, class, and method. The same -site is rebuilt in CI and deployed from `main`, so the published reference follows the repository. +The [documentation site](https://lukasniessen.github.io/ArchUnitRuby/) combines this guide, a +curated [API guide](https://lukasniessen.github.io/ArchUnitRuby/file.API.html), and a searchable, +source-generated reference. The same site is rebuilt and link-validated in CI before deployment +from `main`, so the published reference follows the repository. ## ⚡ 5 min Quickstart @@ -351,12 +358,12 @@ Check options and returned violations retain their existing behavior. Example debug lines (timestamp prefixes omitted): -```text +~~~ 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 @@ -366,11 +373,11 @@ disabled create no output or log files. The same formatted, numbered reports work with RSpec, Minitest, or standalone checks: -```ruby +~~~ 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`, @@ -400,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 @@ -418,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 @@ -435,6 +514,8 @@ the complete library graph must remain cycle-free. ## 🦊 Contributing +Start with [CONTRIBUTING.md](CONTRIBUTING.md) for the complete development and pull-request guide. + ~~~ bash git clone https://github.com/LukasNiessen/ArchUnitRuby.git cd ArchUnitRuby @@ -465,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. @@ -482,6 +563,8 @@ The implementation conventions and intended dependency directions live in [`AGEN See everyone who has contributed on the [GitHub contributors page](https://github.com/LukasNiessen/ArchUnitRuby/graphs/contributors). Questions and feature ideas are welcome in [GitHub Issues](https://github.com/LukasNiessen/ArchUnitRuby/issues). +For usage help, responsible vulnerability reporting, and community expectations, see +[SUPPORT.md](SUPPORT.md), [SECURITY.md](SECURITY.md), and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). ### Star History diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..10df734 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Supported versions + +ArchUnitRuby is currently pre-1.0. Security fixes are applied to the latest published gem and the +`main` branch. + +| Version | Supported | +| --- | --- | +| Latest `0.0.x` release | Yes | +| Older releases | No | + +## Reporting a vulnerability + +Please do not open a public issue for a suspected vulnerability. Use GitHub's +[private vulnerability reporting](https://github.com/LukasNiessen/ArchUnitRuby/security/advisories/new) +to share the affected version, impact, reproduction steps, and any suggested mitigation. + +Do not include real credentials, private source code, or customer data. A minimal synthetic +reproduction is preferred. The maintainers aim to acknowledge a report within three business days +and will coordinate validation, remediation, release, and disclosure with the reporter. + +## Security model + +ArchUnitRuby statically reads Ruby source and gemspec text; it does not intentionally execute the +analyzed project or evaluate gemspecs. Dynamic import expressions are omitted instead of executed. +Run architecture analysis with the same filesystem permissions you would grant any test tool, and +review exported reports before sharing them because paths and dependency names may reveal project +structure. + diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..2c76efe --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,22 @@ +# ArchUnitRuby Support + +## Using the library + +Start with the [five-minute quickstart](README.md#-5-min-quickstart), then use the +[API guide](https://lukasniessen.github.io/ArchUnitRuby/file.API.html) and searchable +[reference](https://lukasniessen.github.io/ArchUnitRuby/class_list.html). + +For questions, unexpected rule output, or feature ideas, search and then open a +[GitHub issue](https://github.com/LukasNiessen/ArchUnitRuby/issues). Include: + +- ArchUnitRuby and Ruby versions; +- operating system and test framework; +- the smallest architecture rule and project layout that reproduce the behavior; +- expected and actual violations, with sensitive paths or code removed. + +The API is pre-release and may change before 1.0. Current limitations are documented in the +[README](README.md#-plans-and-current-limitations). + +Suspected vulnerabilities belong in a private report following [SECURITY.md](SECURITY.md), never in +a public support issue. + diff --git a/archunit.gemspec b/archunit.gemspec index 9f7c0ad..425da02 100644 --- a/archunit.gemspec +++ b/archunit.gemspec @@ -20,7 +20,9 @@ Gem::Specification.new do |spec| spec.metadata['allowed_push_host'] = 'https://rubygems.org' spec.metadata['rubygems_mfa_required'] = 'true' - packaged_files = ['lib/**/*', 'README.md', 'CHANGELOG.md', 'LICENSE'] + packaged_files = [ + 'lib/**/*', 'assets/logo-rounded.png', 'README.md', 'API.md', 'CHANGELOG.md', 'LICENSE' + ] spec.files = Dir[*packaged_files].select { |path| File.file?(path) } spec.require_paths = ['lib'] diff --git a/assets/logo-rounded.png b/assets/logo-rounded.png new file mode 100644 index 0000000..10456f0 Binary files /dev/null and b/assets/logo-rounded.png differ diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..9f44f7f Binary files /dev/null and b/assets/social-preview.png differ diff --git a/benchmark/README.md b/benchmark/README.md index 80d1863..dff8337 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -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 @@ -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 diff --git a/benchmark/extraction.rb b/benchmark/extraction.rb index 9f40c8e..d8f7d5b 100644 --- a/benchmark/extraction.rb +++ b/benchmark/extraction.rb @@ -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 @@ -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 @@ -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)), @@ -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 diff --git a/docs-assets/custom.css b/docs-assets/custom.css index 6094157..8171127 100644 --- a/docs-assets/custom.css +++ b/docs-assets/custom.css @@ -37,6 +37,7 @@ body { font-size: 15px; line-height: 1.6; margin: 0; + width: auto; } a, @@ -99,19 +100,13 @@ input:focus-visible { text-decoration: none; } -.archunit-brand span { +.archunit-brand img { align-items: center; - background: linear-gradient(145deg, var(--au-ruby), var(--au-ruby-dark)); - border: 1px solid rgb(255 255 255 / 16%); border-radius: 8px; box-shadow: 0 8px 20px rgb(185 40 34 / 28%); - color: #fff; - display: inline-flex; - font-family: var(--au-mono); - font-size: 0.72rem; + display: block; height: 2rem; - justify-content: center; - letter-spacing: -0.08em; + object-fit: cover; width: 2rem; } @@ -649,13 +644,16 @@ pre.code .rubyid_include { .archunit-topbar { align-items: flex-start; + flex-direction: column; + gap: 0.85rem; margin: 0 -1.2rem 2rem; padding: 0.9rem 1.2rem; + position: relative; } .archunit-links { gap: 0.75rem; - justify-content: flex-end; + justify-content: flex-start; } .nav_wrap { @@ -672,6 +670,14 @@ pre.code .rubyid_include { #filecontents > h1:first-child { margin-top: 3rem; } + + #toc { + float: none; + margin: 1.5rem 0 2rem; + max-width: none; + position: static; + width: 100%; + } } @media (max-width: 620px) { diff --git a/lib/archunit.rb b/lib/archunit.rb index cbed9cf..1a886f9 100644 --- a/lib/archunit.rb +++ b/lib/archunit.rb @@ -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 @@ -107,6 +108,9 @@ module ArchUnit ResultFactory = Testing::ResultFactory AssertionFailure = Testing::AssertionFailure + # Clears every cached dependency graph in the current process. + # Use this after changing analyzed source files between checks. + # @return [nil] def self.clear_graph_cache Extraction.clear_graph_cache end diff --git a/lib/archunit/common/extraction/dependency_evidence.rb b/lib/archunit/common/extraction/dependency_evidence.rb new file mode 100644 index 0000000..8e5acda --- /dev/null +++ b/lib/archunit/common/extraction/dependency_evidence.rb @@ -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 diff --git a/lib/archunit/common/extraction/edge.rb b/lib/archunit/common/extraction/edge.rb index 9741217..c6c8f78 100644 --- a/lib/archunit/common/extraction/edge.rb +++ b/lib/archunit/common/extraction/edge.rb @@ -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 diff --git a/lib/archunit/common/extraction/import_kind.rb b/lib/archunit/common/extraction/import_kind.rb index 8fe420d..32d7430 100644 --- a/lib/archunit/common/extraction/import_kind.rb +++ b/lib/archunit/common/extraction/import_kind.rb @@ -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) diff --git a/lib/archunit/common/fluentapi/check_options.rb b/lib/archunit/common/fluentapi/check_options.rb index c0a91eb..fd03817 100644 --- a/lib/archunit/common/fluentapi/check_options.rb +++ b/lib/archunit/common/fluentapi/check_options.rb @@ -6,7 +6,12 @@ module ArchUnit module Common module FluentApi # Immutable options shared by every terminal rule check. - CheckOptions = Data.define(:allow_empty_tests, :logging, :clear_cache, :load_paths) do + CheckOptions = Data.define( + :allow_empty_tests, :logging, :clear_cache, :load_paths, :dependency_analysis + ) do + # Normalizes nil or an existing options value for terminal execution. + # @param value [CheckOptions, nil] + # @return [CheckOptions] def self.resolve(value) return new if value.nil? return value if value.is_a?(self) @@ -14,22 +19,40 @@ def self.resolve(value) raise ArgumentError, 'options must be a CheckOptions value or nil' end - def initialize(allow_empty_tests: false, logging: nil, clear_cache: false, load_paths: []) + # @param allow_empty_tests [Boolean] permit a selector that finds no subjects + # @param logging [LoggingOptions, nil] per-check logging configuration + # @param clear_cache [Boolean] rebuild the dependency graph for this check + # @param load_paths [Array] additional project-contained source roots + # @param dependency_analysis [Symbol] :imports or opt-in :imports_and_constants + def initialize( + allow_empty_tests: false, logging: nil, clear_cache: false, load_paths: [], + dependency_analysis: :imports + ) validate_boolean(allow_empty_tests, :allow_empty_tests) validate_logging(logging) validate_boolean(clear_cache, :clear_cache) load_paths = immutable_load_paths(load_paths) + unless %i[imports imports_and_constants].include?(dependency_analysis) + raise ArgumentError, 'dependency_analysis must be :imports or :imports_and_constants' + end + super end + # @return [Boolean] whether an empty subject selection is allowed def allow_empty_tests? allow_empty_tests end + # @return [Boolean] whether this check rebuilds the cached graph def clear_cache? clear_cache end + def constant_references? + dependency_analysis == :imports_and_constants + end + private def validate_boolean(value, attribute) diff --git a/lib/archunit/common/fluentapi/checkable.rb b/lib/archunit/common/fluentapi/checkable.rb index 39670cb..b9796dd 100644 --- a/lib/archunit/common/fluentapi/checkable.rb +++ b/lib/archunit/common/fluentapi/checkable.rb @@ -11,6 +11,9 @@ module Common module FluentApi # The shared execution contract implemented by every terminal rule. module Checkable + # Executes the rule and returns every architecture disagreement as structured data. + # @param options [CheckOptions, nil] + # @return [Array] def check(options = nil) resolved_options = CheckOptions.resolve(options) logger = Logging::CheckLogger.new(resolved_options.logging) diff --git a/lib/archunit/common/logging/logging_options.rb b/lib/archunit/common/logging/logging_options.rb index c487ce3..789ad19 100644 --- a/lib/archunit/common/logging/logging_options.rb +++ b/lib/archunit/common/logging/logging_options.rb @@ -8,6 +8,10 @@ module Logging # Immutable, per-check logging configuration. A nil CheckOptions#logging disables logging. LoggingOptions = Data.define(:level, :io, :output_directory, :append) do + # @param level [Symbol, String] one of debug, info, warn, or error + # @param io [#write, nil] stream sink, or nil to disable stream output + # @param output_directory [String, Pathname, nil] optional directory for timestamped logs + # @param append [Boolean] append to an existing selected log file when possible def initialize(level: :info, io: $stderr, output_directory: nil, append: false) level = normalize_level(level) validate_io(io) @@ -16,6 +20,7 @@ def initialize(level: :info, io: $stderr, output_directory: nil, append: false) super end + # @return [Boolean] whether file logging is configured def file_output? !output_directory.nil? end diff --git a/lib/archunit/common/pattern.rb b/lib/archunit/common/pattern.rb index 0fdbfbe..a385fb7 100644 --- a/lib/archunit/common/pattern.rb +++ b/lib/archunit/common/pattern.rb @@ -37,18 +37,28 @@ def compile_glob(glob) def compile_fragment(characters, index) case characters[index] - when '*' - compile_star(characters, index) - when '?' - ['[^/]', index + 1] - when '[' - compile_character_class(characters, index) - else - [Regexp.escape(characters[index]), index + 1] + when '*' then compile_star(characters, index) + when '/' then compile_slash(characters, index) + when '?' then ['[^/]', index + 1] + when '[' then compile_character_class(characters, index) + else [Regexp.escape(characters[index]), index + 1] end end private_class_method :compile_fragment + def compile_slash(characters, index) + globstar_index = index + 1 + globstar = characters[globstar_index] == '*' && characters[globstar_index + 1] == '*' + return ['/', globstar_index] unless globstar + + tail_index = globstar_index + 2 + tail_index += 1 while characters[tail_index] == '*' + return ['/', globstar_index] unless tail_index == characters.length + + ['(?:/.*)?', tail_index] + end + private_class_method :compile_slash + def compile_star(characters, index) return ['[^/]*', index + 1] unless characters[index + 1] == '*' diff --git a/lib/archunit/extraction/conservative_local_flow.rb b/lib/archunit/extraction/conservative_local_flow.rb new file mode 100644 index 0000000..0dc78f1 --- /dev/null +++ b/lib/archunit/extraction/conservative_local_flow.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module ArchUnit + module Extraction + # Keeps local-string inference inside straight-line code and lexical scopes. + module ConservativeLocalFlow + def visit_def_node(node) + @local_strings.isolated_scope { super } + end + + def visit_class_node(node) + @local_strings.isolated_scope { super } + end + + def visit_module_node(node) + @local_strings.isolated_scope { super } + end + + def visit_singleton_class_node(node) + @local_strings.isolated_scope { super } + end + + def visit_block_node(node) + @local_strings.isolated_scope { super } + @local_strings.capture_scopes + end + + def visit_lambda_node(node) + @local_strings.isolated_scope { super } + @local_strings.capture_scopes + end + + def visit_local_variable_operator_write_node(node) + super + @local_strings.assign(node.name, nil) + end + + def visit_local_variable_or_write_node(node) + super + @local_strings.assign(node.name, nil) + end + + def visit_local_variable_and_write_node(node) + super + @local_strings.assign(node.name, nil) + end + + def visit_multi_write_node(node) + super + @local_strings.invalidate_all + end + + def visit_post_execution_node(node) + @local_strings.uncertain_flow { super } + @local_strings.capture_scopes + end + + def visit_match_write_node(node) + super + @local_strings.invalidate_all + end + + def visit_instance_variable_write_node(node) + super + @local_strings.invalidate_escaped(node.value) + end + + def visit_global_variable_write_node(node) + super + @local_strings.invalidate_escaped(node.value) + end + + def visit_class_variable_write_node(node) + super + @local_strings.invalidate_escaped(node.value) + end + + def visit_constant_write_node(node) + super + @local_strings.invalidate_escaped(node.value) + end + + def visit_constant_path_write_node(node) + super + @local_strings.invalidate_escaped(node.value) + end + + # These constructs require control-flow analysis beyond the supported straight-line subset. + %i[ + begin defined pre_execution if unless and or case case_match while until for + rescue rescue_modifier + ].each do |kind| + define_method("visit_#{kind}_node") do |node| + @local_strings.uncertain_flow { super(node) } + end + end + end + private_constant :ConservativeLocalFlow + end +end diff --git a/lib/archunit/extraction/constant_index.rb b/lib/archunit/extraction/constant_index.rb new file mode 100644 index 0000000..6a99aa2 --- /dev/null +++ b/lib/archunit/extraction/constant_index.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require_relative '../common/extraction/edge' +require_relative 'constant_visitor' + +module ArchUnit + module Extraction + # Bounded project definition index. Unknown/ambiguous names never invent file targets. + class ConstantIndex + Location = Data.define(:source, :line_number) + MAX_NAMESPACE_PASSES = 8 + + def initialize(analyses, profile: nil) + @analyses = analyses + @profile = profile + @visitors = stabilized_visitors + @definitions = definition_locations + @namespaces = namespace_names(@visitors) + @inherited_namespaces = @visitors.values.flat_map(&:inherited_namespaces).to_set + end + + def edges + @visitors.flat_map do |source, visitor| + visitor.references.filter_map { |reference| resolve_edge(source, reference) } + end + end + + private + + def stabilized_visitors + visitors = collect + MAX_NAMESPACE_PASSES.times do + names = namespace_names(visitors) + visitors = collect(namespace_names: names) + return visitors if namespace_names(visitors) == names + end + unresolved_count = visitors.values.sum { |visitor| visitor.references.length } + @profile&.increment(:unresolved_constant_references, unresolved_count) + {} + end + + def collect(namespace_names: nil) + @analyses.to_h do |source, result| + visitor = ConstantVisitor.new(namespace_names:) + visitor.visit(result.value) if result.success? + [source, visitor] + end + end + + def namespace_names(visitors) + visitors.values.flat_map(&:definitions).each_with_object(Set.new) do |definition, names| + parts = definition.name.split('::') + parts.length.times { |index| names << parts.take(index + 1).join('::') } + end + end + + def definition_locations + map = Hash.new { |hash, key| hash[key] = [] } + @visitors.each do |source, visitor| + visitor.definitions.each do |definition| + map[definition.name] << Location.new(source:, line_number: definition.line_number) + end + end + map + end + + def resolve_edge(source, reference) + name = resolve_name(reference) + locations = name ? @definitions.fetch(name, []) : [] + return unresolved(:unresolved_constant_references) if locations.empty? + return unresolved(:ambiguous_constant_references) if locations.map(&:source).uniq.length > 1 + + location = locations.first + return if source == location.source + + evidence_edge(source, reference, location) + end + + def resolve_name(reference) + name = reference.name + return name.delete_prefix('::') if name.start_with?('::') + + head = name.split('::').first + owner = reference.nesting.find { |scope| @namespaces.include?("#{scope}::#{head}") } + return "#{owner}::#{name}" if owner + return if @inherited_namespaces.include?(reference.inherited_scope) + + name + end + + def unresolved(counter) + @profile&.increment(counter) + nil + end + + def evidence_edge(source, reference, location) + kind = Common::Extraction::ImportKind::CONSTANT_REFERENCE + evidence = Common::Extraction::DependencyEvidence.new( + source:, kind:, name: reference.name, line_number: reference.line_number, + end_line: reference.end_line, target_line_number: location.line_number + ) + Common::Extraction::Edge.new( + source:, target: location.source, external: false, + import_kinds: [kind], evidence: [evidence] + ) + end + end + private_constant :ConstantIndex + end +end diff --git a/lib/archunit/extraction/constant_visitor.rb b/lib/archunit/extraction/constant_visitor.rb new file mode 100644 index 0000000..c34205f --- /dev/null +++ b/lib/archunit/extraction/constant_visitor.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'prism' + +module ArchUnit + module Extraction + # Collects declarations and uses with Ruby's lexical (not textual path) nesting. + class ConstantVisitor < Prism::Visitor + Definition = Data.define(:name, :line_number) + Reference = Data.define(:name, :nesting, :inherited_scope, :line_number, :end_line) + + attr_reader :definitions, :references, :inherited_namespaces + + def initialize(namespace_names: nil) + super() + @namespace_names = namespace_names + @definitions = [] + @references = [] + @nesting = [] + @inherited_namespaces = [] + end + + def visit_class_node(node) + visit(node.superclass) + visit_namespace(node, inherited: !node.superclass.nil?) + end + + def visit_module_node(node) + visit_namespace(node, inherited: false) + end + + def visit_constant_write_node(node) + define_constant(qualify(node.name.to_s), node) + visit(node.value) + end + + def visit_constant_path_write_node(node) + name = static_name(node.target) + define_constant(declaration_name(name), node) if name + visit(node.value) + end + + def visit_constant_read_node(node) + record_reference(node.name.to_s, node) + end + + def visit_constant_path_node(node) + name = static_name(node) + name ? record_reference(name, node) : super + end + + def visit_call_node(node) + if %i[include prepend extend const_set remove_const autoload].include?(node.name) && + !@nesting.empty? + @inherited_namespaces << @nesting.first + end + super + end + + def visit_singleton_class_node(node) + visit(node.expression) + previous = @dynamic_scope + @dynamic_scope = true + visit(node.body) + ensure + @dynamic_scope = previous + end + + private + + def visit_namespace(node, inherited:) + name = static_name(node.constant_path) + return unless name + + full_name = declaration_name(name) + define_constant(full_name, node.constant_path) + @inherited_namespaces << full_name if inherited + previous = @nesting + @nesting = [full_name, *previous] + visit(node.body) + ensure + @nesting = previous if previous + end + + def declaration_name(name) + return name.delete_prefix('::') if name.start_with?('::') || @nesting.empty? + return qualify(name) unless name.include?('::') + + head = name.split('::').first + owner = @nesting.find { |scope| @namespace_names&.include?("#{scope}::#{head}") } + owner ? "#{owner}::#{name}" : name + end + + def qualify(name) + @nesting.empty? ? name : "#{@nesting.first}::#{name}" + end + + def define_constant(name, node) + return if @dynamic_scope + + @definitions << Definition.new(name:, line_number: node.location.start_line) + end + + def record_reference(name, node) + return if @dynamic_scope && !name.start_with?('::') + + @references << Reference.new( + name:, nesting: @nesting.dup.freeze, inherited_scope: @nesting.first, + line_number: node.location.start_line, end_line: node.location.end_line + ) + end + + def static_name(node) + return node.name.to_s if node.is_a?(Prism::ConstantReadNode) + return unless node.is_a?(Prism::ConstantPathNode) + return "::#{node.name}" if node.parent.nil? + + parent = static_name(node.parent) + "#{parent}::#{node.name}" if parent + end + end + private_constant :ConstantVisitor + end +end diff --git a/lib/archunit/extraction/extract_dependencies.rb b/lib/archunit/extraction/extract_dependencies.rb index 06eb64a..0c2f8ad 100644 --- a/lib/archunit/extraction/extract_dependencies.rb +++ b/lib/archunit/extraction/extract_dependencies.rb @@ -6,11 +6,14 @@ require_relative 'enumerate_source_files' require_relative 'extract_imports' require_relative 'resolve_load_paths' +require_relative 'constant_index' module ArchUnit # Turns statically resolvable Ruby imports into dependency edges. module Extraction - DependencyContext = Data.define(:root, :targets, :load_paths, :profile, :cache) + DependencyContext = Data.define( + :root, :targets, :load_paths, :profile, :cache, :include_evidence + ) private_constant :DependencyContext module_function @@ -22,18 +25,21 @@ def extract_dependencies(project_root, exclude_patterns: nil, options: nil) load_paths = LoadPaths.resolve( root, explicit_paths: options.load_paths, source_files: ) - extract_dependencies_from(root, source_files, load_paths:) + extract_dependencies_from(root, source_files, load_paths:, options:) end - def extract_dependencies_from(root, source_files, load_paths:, profile: nil) + def extract_dependencies_from(root, source_files, load_paths:, options:, profile: nil) context = DependencyContext.new( root:, targets: profiled_target_index(root, source_files, profile), load_paths:, - profile:, cache: {} + profile:, cache: {}, include_evidence: options.constant_references? ) - source_files.flat_map do |source| - dependencies_for_source(source, context) - end.freeze + analyses = options.constant_references? ? parse_sources(root, source_files, profile) : {} + edges = source_files.flat_map do |source| + dependencies_for_source(source, context, analyses[source]) + end + edges.concat(constant_edges(analyses, profile)) if options.constant_references? + edges.freeze end private_class_method :extract_dependencies_from @@ -44,13 +50,26 @@ def profiled_target_index(root, source_files, profile) end private_class_method :profiled_target_index - def dependencies_for_source(source, context) + def parse_sources(root, sources, profile) + sources.to_h { |source| [source, parse_source(root.join(source), profile)] } + end + private_class_method :parse_sources + + def constant_edges(analyses, profile) + ExtractionProfile.measure(profile, :constant_indexing) do + ConstantIndex.new(analyses, profile:).edges + end + end + private_class_method :constant_edges + + def dependencies_for_source(source, context, parse_result) imports = extract_imports_from( context.root.join(source), project_root: context.root, load_paths: context.load_paths, profile: context.profile, - resolution_cache: context.cache + resolution_cache: context.cache, + parse_result: ) context.profile&.increment(:imports, imports.length) classify_imports(source, imports, context) @@ -59,7 +78,11 @@ def dependencies_for_source(source, context) def classify_imports(source, imports, context) ExtractionProfile.measure(context.profile, :edge_classification) do - imports.map { |import| dependency_edge(source, import, context.targets) } + imports.map do |import| + dependency_edge( + source, import, context.targets, include_evidence: context.include_evidence + ) + end end end private_class_method :classify_imports @@ -76,18 +99,27 @@ def internal_target_index(root, source_files) end private_class_method :internal_target_index - def dependency_edge(source, import, internal_targets) + def dependency_edge(source, import, internal_targets, include_evidence:) target = import.resolved_path && internal_targets[canonical_path(import.resolved_path)] Common::Extraction::Edge.new( source: source, target: target || import.module_name, external: target.nil?, - import_kinds: [import.import_kind] + import_kinds: [import.import_kind], + evidence: include_evidence ? [import_evidence(source, import)] : [] ) end private_class_method :dependency_edge + def import_evidence(source, import) + Common::Extraction::DependencyEvidence.new( + source:, kind: import.import_kind, name: import.module_name, + line_number: import.line_number, end_line: import.end_line + ) + end + private_class_method :import_evidence + def canonical_path(path) normalized = Pathname.new(path).expand_path.cleanpath.to_s.tr('\\', '/') File::FNM_SYSCASE.zero? ? normalized : normalized.downcase diff --git a/lib/archunit/extraction/extract_graph.rb b/lib/archunit/extraction/extract_graph.rb index b4390a9..b18ed3c 100644 --- a/lib/archunit/extraction/extract_graph.rb +++ b/lib/archunit/extraction/extract_graph.rb @@ -63,14 +63,14 @@ def extract_graph_uncached(root, exclude_patterns, options, profile) load_paths = ExtractionProfile.measure(profile, :load_path_discovery) do LoadPaths.resolve(root, explicit_paths: options.load_paths, source_files:) end - edges = extraction_edges(root, source_files, load_paths, profile) + edges = extraction_edges(root, source_files, load_paths, options, profile) graph_from_edges(edges, profile) end private_class_method :extract_graph_uncached - def extraction_edges(root, source_files, load_paths, profile) + def extraction_edges(root, source_files, load_paths, options, profile) edges = self_edges(source_files) + extract_dependencies_from( - root, source_files, load_paths:, profile: + root, source_files, load_paths:, profile:, options: ) profile&.increment(:raw_edges, edges.length) edges @@ -123,7 +123,8 @@ def merge_parallel_edges(edges) source: first.source, target: first.target, external: first.external, - import_kinds: parallel_edges.flat_map(&:import_kinds) + import_kinds: parallel_edges.flat_map(&:import_kinds), + evidence: parallel_edges.flat_map(&:evidence) ) end end diff --git a/lib/archunit/extraction/extract_imports.rb b/lib/archunit/extraction/extract_imports.rb index 6b84b53..c079405 100644 --- a/lib/archunit/extraction/extract_imports.rb +++ b/lib/archunit/extraction/extract_imports.rb @@ -30,13 +30,16 @@ def extract_imports( raise TechnicalError, "could not parse Ruby source file: #{e.message}" end - def extract_imports_from( - source_path, project_root:, load_paths: nil, profile: nil, resolution_cache: nil + # Share the parsed AST with constant analysis. + def extract_imports_from( # rubocop:disable Metrics/ParameterLists + source_path, project_root:, load_paths: nil, profile: nil, resolution_cache: nil, + parse_result: nil ) resolver = build_import_resolver( source_path:, project_root:, load_paths:, profile:, cache: resolution_cache ) - resolve_imports(parsed_imports(source_path, profile), resolver) + parse_result ||= parse_source(source_path, profile) + resolve_imports(parsed_imports(parse_result, profile), resolver) rescue SystemCallError => e raise TechnicalError, "could not parse Ruby source file: #{e.message}" end @@ -52,16 +55,23 @@ def resolve_imports(imports, resolver) end private_class_method :resolve_imports - def parsed_imports(source_path, profile) + def parse_source(source_path, profile) source = ExtractionProfile.measure(profile, :file_read) { File.binread(source_path) } - parse_result = ExtractionProfile.measure(profile, :prism_parse) do + ExtractionProfile.measure(profile, :prism_parse) do Prism.parse(source, filepath: source_path.to_s) end + rescue SystemCallError => e + raise TechnicalError, "could not parse Ruby source file: #{e.message}" + end + private_class_method :parse_source + + def parsed_imports(parse_result, profile) return [] unless parse_result.success? ExtractionProfile.measure(profile, :import_extraction) do visitor = ImportVisitor.new visitor.visit(parse_result.value) + profile&.increment(:unresolved_imports, visitor.unresolved_imports) reject_ignored_imports(visitor.imports, parse_result.comments) end end @@ -108,11 +118,12 @@ def ignored_module?(module_name, scoped_name) private_class_method :ignored_module? def resolved_import(located_import, resolver) - module_name, import_kind, line_number, = located_import + module_name, import_kind, line_number, end_line = located_import ResolvedImport.new( module_name: module_name, import_kind: import_kind, line_number: line_number, + end_line: end_line, resolved_path: resolver.resolve(module_name, import_kind) ) end diff --git a/lib/archunit/extraction/extraction_profile.rb b/lib/archunit/extraction/extraction_profile.rb index 372f2d3..2adf0f6 100644 --- a/lib/archunit/extraction/extraction_profile.rb +++ b/lib/archunit/extraction/extraction_profile.rb @@ -12,6 +12,7 @@ class ExtractionProfile file_read prism_parse import_extraction + constant_indexing target_resolution target_index edge_classification @@ -20,6 +21,9 @@ class ExtractionProfile COUNTERS = %i[ source_files imports + unresolved_imports + unresolved_constant_references + ambiguous_constant_references resolution_cache_hits resolution_cache_misses raw_edges diff --git a/lib/archunit/extraction/import_visitor.rb b/lib/archunit/extraction/import_visitor.rb index 34175bb..873d704 100644 --- a/lib/archunit/extraction/import_visitor.rb +++ b/lib/archunit/extraction/import_visitor.rb @@ -2,11 +2,15 @@ require 'prism' require_relative '../common/extraction/import_kind' +require_relative 'conservative_local_flow' +require_relative 'local_string_tracker' module ArchUnit module Extraction - # Collects supported literal dependency calls from a Prism syntax tree. + # Collects supported dependency calls from a Prism syntax tree. class ImportVisitor < Prism::Visitor + include ConservativeLocalFlow + CALL_KINDS = { require: Common::Extraction::ImportKind::REQUIRE, require_relative: Common::Extraction::ImportKind::REQUIRE_RELATIVE, @@ -14,38 +18,54 @@ class ImportVisitor < Prism::Visitor load: Common::Extraction::ImportKind::LOAD }.freeze - attr_reader :imports + attr_reader :imports, :unresolved_imports def initialize super @imports = [] + @unresolved_imports = 0 + @local_strings = LocalStringTracker.new end def visit_call_node(node) - record_import(node) + import_call = supported_import_call?(node) + record_import(node) if import_call + super + invalidate_escaped_locals(node) unless import_call + end + + def visit_local_variable_write_node(node) super + @local_strings.assign(node.name, node.value) end private def record_import(node) import_kind = CALL_KINDS[node.name] - return unless import_kind && supported_receiver?(node, import_kind) - arguments = node.arguments&.arguments || [] argument = import_argument(arguments, import_kind) - return unless argument.is_a?(Prism::StringNode) - return unless valid_module_name?(argument.unescaped) + module_name = @local_strings.resolve(argument) + unless valid_module_name?(module_name) + @unresolved_imports += 1 + return + end - @imports << located_import(argument, import_kind, node) + @imports << located_import(module_name, import_kind, node) end - def located_import(argument, import_kind, node) - [argument.unescaped, import_kind, node.location.start_line, node.location.end_line] + def located_import(module_name, import_kind, node) + [module_name, import_kind, node.location.start_line, node.location.end_line] end def valid_module_name?(module_name) - !module_name.empty? && !module_name.include?("\0") + module_name.is_a?(String) && !module_name.empty? && !module_name.include?("\0") + end + + def supported_import_call?(node) + import_kind = CALL_KINDS[node.name] + import_kind && supported_receiver?(node, import_kind) && + !import_argument(node.arguments&.arguments || [], import_kind).nil? end def supported_receiver?(node, import_kind) @@ -73,6 +93,11 @@ def import_argument(arguments, import_kind) arguments[0] end end + + def invalidate_escaped_locals(node) + @local_strings.invalidate_escaped(node) + @local_strings.capture_scopes if %i[eval binding local_variable_set].include?(node.name) + end end private_constant :ImportVisitor end diff --git a/lib/archunit/extraction/local_string_tracker.rb b/lib/archunit/extraction/local_string_tracker.rb new file mode 100644 index 0000000..7f5be4e --- /dev/null +++ b/lib/archunit/extraction/local_string_tracker.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'prism' + +module ArchUnit + module Extraction + # Conservatively tracks local variables whose current value is a literal String. + class LocalStringTracker + UNKNOWN = Object.new.freeze + private_constant :UNKNOWN + + def initialize + @scopes = [{}] + @captured_scopes = [false] + @uncertain_depth = 0 + end + + def resolve(node) + return node.unescaped if node.is_a?(Prism::StringNode) + return UNKNOWN if @uncertain_depth.positive? || @captured_scopes.last + return scope.fetch(node.name, UNKNOWN) if node.is_a?(Prism::LocalVariableReadNode) + + UNKNOWN + end + + def assign(name, value) + invalidate_escaped(value) if value && !value.is_a?(Prism::StringNode) + scope[name] = resolve(value) + end + + def invalidate_escaped(node) + local_reads(node).each { |name| scope[name] = UNKNOWN if scope.key?(name) } + end + + def isolated_scope + @scopes << {} + @captured_scopes << false + yield + ensure + @scopes.pop + @captured_scopes.pop + end + + def invalidate_all + @scopes.each { |values| values.transform_values! { UNKNOWN } } + end + + def capture_scopes + @captured_scopes.map! { true } + invalidate_all + end + + def uncertain_flow + invalidate_scope + @uncertain_depth += 1 + yield + ensure + @uncertain_depth -= 1 + invalidate_scope + end + + private + + def local_reads(node) + return [node.name] if node.is_a?(Prism::LocalVariableReadNode) + + node.compact_child_nodes.flat_map { |child| local_reads(child) }.uniq + end + + def invalidate_scope + scope.transform_values! { UNKNOWN } + end + + def scope + @scopes.last + end + end + private_constant :LocalStringTracker + end +end diff --git a/lib/archunit/extraction/resolved_import.rb b/lib/archunit/extraction/resolved_import.rb index f988d4b..038897d 100644 --- a/lib/archunit/extraction/resolved_import.rb +++ b/lib/archunit/extraction/resolved_import.rb @@ -4,11 +4,14 @@ module ArchUnit module Extraction - ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path) do - def initialize(module_name:, import_kind:, line_number:, resolved_path: nil) + ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path, + :end_line) do + def initialize(module_name:, import_kind:, line_number:, resolved_path: nil, + end_line: line_number) validate_module_name(module_name) validate_import_kind(import_kind) validate_line_number(line_number) + validate_line_number(end_line) validate_resolved_path(resolved_path) module_name = module_name.dup.freeze diff --git a/lib/archunit/files/extraction/file_info.rb b/lib/archunit/files/extraction/file_info.rb index 21021c8..12e0bab 100644 --- a/lib/archunit/files/extraction/file_info.rb +++ b/lib/archunit/files/extraction/file_info.rb @@ -8,7 +8,7 @@ module Extraction FileInfo = Data.define( :path, :name, :extension, :directory, :content, :lines_of_code ) do - # rubocop:disable Metrics/ParameterLists -- FileInfo has six required public fields. + # rubocop:disable-next Metrics/ParameterLists -- FileInfo has six required public fields. def initialize(path:, name:, extension:, directory:, content:, lines_of_code:) path = immutable_string(path, :path, allow_empty: false, normalize_path: true) name = immutable_string(name, :name, allow_empty: false) @@ -20,7 +20,6 @@ def initialize(path:, name:, extension:, directory:, content:, lines_of_code:) lines_of_code = non_negative_integer(lines_of_code) super end - # rubocop:enable Metrics/ParameterLists private diff --git a/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb b/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb index 2a65dbd..7360400 100644 --- a/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +++ b/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb @@ -34,6 +34,8 @@ def negated? is_negated end + # Adds another external module pattern using OR semantics. + # @return [DependOnExternalModuleCondition] def matching(module_name, except: nil) filter = Common::RegexFactory.path_matcher(module_name, except:) self.class.new(builder, module_filters: [*module_filters, filter]) diff --git a/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb b/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb index 387ed4c..7e26b0f 100644 --- a/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +++ b/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb @@ -26,6 +26,8 @@ def negated? is_negated end + # Selects an allowed or forbidden external module name. + # @return [DependOnExternalModuleCondition] def matching(module_name, except: nil) filter = Common::RegexFactory.path_matcher(module_name, except:) DependOnExternalModuleCondition.new(self, module_filters: [filter]) diff --git a/lib/archunit/files/fluentapi/depend_on_file_condition.rb b/lib/archunit/files/fluentapi/depend_on_file_condition.rb index 5ca6f05..9e8b3e0 100644 --- a/lib/archunit/files/fluentapi/depend_on_file_condition.rb +++ b/lib/archunit/files/fluentapi/depend_on_file_condition.rb @@ -34,14 +34,20 @@ def negated? is_negated end + # Adds another allowed or forbidden basename using OR semantics. + # @return [DependOnFileCondition] def with_name(pattern, except: nil) with_filter(Common::RegexFactory.filename_matcher(pattern, except:)) end + # Adds another allowed or forbidden folder using OR semantics. + # @return [DependOnFileCondition] def in_folder(pattern, except: nil) with_filter(Common::RegexFactory.folder_matcher(pattern, except:)) end + # Adds another allowed or forbidden path using OR semantics. + # @return [DependOnFileCondition] def in_path(pattern, except: nil) with_filter(Common::RegexFactory.path_matcher(pattern, except:)) end diff --git a/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb b/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb index b0c7c82..9d46796 100644 --- a/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +++ b/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb @@ -26,14 +26,20 @@ def negated? is_negated end + # Selects dependency targets by basename. + # @return [DependOnFileCondition] def with_name(pattern, except: nil) condition(Common::RegexFactory.filename_matcher(pattern, except:)) end + # Selects dependency targets by containing folder. + # @return [DependOnFileCondition] def in_folder(pattern, except: nil) condition(Common::RegexFactory.folder_matcher(pattern, except:)) end + # Selects dependency targets by complete path. + # @return [DependOnFileCondition] def in_path(pattern, except: nil) condition(Common::RegexFactory.path_matcher(pattern, except:)) end diff --git a/lib/archunit/files/fluentapi/file_condition_builder.rb b/lib/archunit/files/fluentapi/file_condition_builder.rb index 614e395..7f12b4a 100644 --- a/lib/archunit/files/fluentapi/file_condition_builder.rb +++ b/lib/archunit/files/fluentapi/file_condition_builder.rb @@ -16,26 +16,38 @@ def initialize(project_locator: nil, filters: []) freeze end + # Selects files by basename. + # @return [FileConditionBuilder] def with_name(pattern, except: nil) with_filter(Common::RegexFactory.filename_matcher(pattern, except:)) end + # Selects files by project-relative containing folder. + # @return [FileConditionBuilder] def in_folder(pattern, except: nil) with_filter(Common::RegexFactory.folder_matcher(pattern, except:)) end + # Selects files by complete project-relative path. + # @return [FileConditionBuilder] def in_path(pattern, except: nil) with_filter(Common::RegexFactory.path_matcher(pattern, except:)) end + # Selects one exact project-relative file. + # @return [FileConditionBuilder] def in_file(file_path, except: nil) with_filter(Common::RegexFactory.exact_file_matcher(file_path, except:)) end + # Enters the positive rule mood. + # @return [PositiveMatchPatternFileConditionBuilder] def should PositiveMatchPatternFileConditionBuilder.new(self) end + # Enters the negated rule mood. + # @return [NegatedMatchPatternFileConditionBuilder] def should_not NegatedMatchPatternFileConditionBuilder.new(self) end diff --git a/lib/archunit/files/fluentapi/files.rb b/lib/archunit/files/fluentapi/files.rb index aa52469..d7c420c 100644 --- a/lib/archunit/files/fluentapi/files.rb +++ b/lib/archunit/files/fluentapi/files.rb @@ -11,6 +11,9 @@ module Files module FluentApi module_function + # Starts an immutable file-rule sentence. + # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec + # @return [FileConditionBuilder] def project_files(project_locator = nil) FileConditionBuilder.new(project_locator:) end @@ -21,6 +24,9 @@ class << self end end + # Starts an immutable file-rule sentence. + # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec + # @return [Files::FluentApi::FileConditionBuilder] def self.project_files(project_locator = nil) Files::FluentApi.project_files(project_locator) end diff --git a/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb b/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb index 3cdad7a..5f7fe68 100644 --- a/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +++ b/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb @@ -31,26 +31,40 @@ def negated? @negated end + # Requires selected files to match a basename pattern. + # @return [MatchPatternFileCondition] def have_name(pattern, except: nil) matching(Common::RegexFactory.filename_matcher(pattern, except:)) end + # Requires selected files to match a containing-folder pattern. + # @return [MatchPatternFileCondition] def be_in_folder(pattern, except: nil) matching(Common::RegexFactory.folder_matcher(pattern, except:)) end + # Requires selected files to match a complete-path pattern. + # @return [MatchPatternFileCondition] def be_in_path(pattern, except: nil) matching(Common::RegexFactory.path_matcher(pattern, except:)) end + # Starts the object stage for internal file dependencies. + # @return [DependOnFileConditionBuilder] def depend_on_files DependOnFileConditionBuilder.new(self) end + # Starts the object stage for standard-library and gem imports. + # @return [DependOnExternalModuleConditionBuilder] def depend_on_external_modules DependOnExternalModuleConditionBuilder.new(self) end + # Applies a custom predicate to each selected FileInfo. + # @param condition [#call] predicate receiving one FileInfo + # @param message [String] explanation used for violations + # @return [CustomFileCondition] def adhere_to(condition, message) CustomFileCondition.new(self, condition:, message:) end diff --git a/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb b/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb index 49a5c2b..c80a0bb 100644 --- a/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +++ b/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb @@ -12,6 +12,8 @@ def initialize(scope) super(scope, negated: false) end + # Requires the selected internal dependency graph to be cycle-free. + # @return [CycleFreeFileCondition] def have_no_cycles CycleFreeFileCondition.new(self) end diff --git a/lib/archunit/graph/fluentapi/graph.rb b/lib/archunit/graph/fluentapi/graph.rb index b7e1746..ebaeb65 100644 --- a/lib/archunit/graph/fluentapi/graph.rb +++ b/lib/archunit/graph/fluentapi/graph.rb @@ -9,6 +9,8 @@ module GraphReporting module FluentApi module_function + # Starts an immutable dependency-graph report. + # @return [ProjectGraphBuilder] def project_graph(project_locator = nil) ProjectGraphBuilder.new(project_locator:) end @@ -19,6 +21,9 @@ class << self end end + # Starts an immutable dependency-graph report. + # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec + # @return [GraphReporting::FluentApi::ProjectGraphBuilder] def self.project_graph(project_locator = nil) GraphReporting::FluentApi.project_graph(project_locator) end diff --git a/lib/archunit/graph/fluentapi/project_graph_builder.rb b/lib/archunit/graph/fluentapi/project_graph_builder.rb index 4d9e99a..cea999b 100644 --- a/lib/archunit/graph/fluentapi/project_graph_builder.rb +++ b/lib/archunit/graph/fluentapi/project_graph_builder.rb @@ -10,6 +10,30 @@ module ArchUnit module GraphReporting module FluentApi # Immutable query builder for dependency graph snapshots and reports. + # @!method to_dot + # @return [String] the current graph report as Graphviz DOT + # @!method to_mermaid + # @return [String] the current graph report as Mermaid + # @!method to_d2 + # @return [String] the current graph report as D2 + # @!method to_csv + # @return [String] the current graph report as CSV + # @!method to_json + # @return [String] the current graph report as JSON + # @!method to_html + # @return [String] the current graph report as self-contained HTML + # @!method export_as_dot(output_path) + # @return [nil] writes Graphviz DOT to output_path + # @!method export_as_mermaid(output_path) + # @return [nil] writes Mermaid to output_path + # @!method export_as_d2(output_path) + # @return [nil] writes D2 to output_path + # @!method export_as_csv(output_path) + # @return [nil] writes CSV to output_path + # @!method export_as_json(output_path) + # @return [nil] writes JSON to output_path + # @!method export_as_html(output_path) + # @return [nil] writes self-contained HTML to output_path class ProjectGraphBuilder attr_reader :project_locator, :options, :check_options @@ -20,51 +44,71 @@ def initialize(project_locator: nil, options: nil, check_options: nil) freeze end + # Includes standard-library and gem imports in the report. + # @return [ProjectGraphBuilder] def include_external_dependencies with_options(options.with(include_external_dependencies: true)) end + # Includes the self-edges that represent dependency-free source files. + # @return [ProjectGraphBuilder] def include_self_dependencies with_options(options.with(include_self_dependencies: true)) end + # Keeps matching nodes and neighbors within depth hops. + # @return [ProjectGraphBuilder] def focus_on(pattern, depth = 1, except: nil) filter = Common::RegexFactory.path_matcher(pattern, except:) with_options(options.with(focus: filter, focus_depth: depth)) end + # Keeps nodes reachable from matching starting nodes. + # @return [ProjectGraphBuilder] def reachable_from(pattern, except: nil) filter = Common::RegexFactory.path_matcher(pattern, except:) with_options(options.with(reachable_from: filter)) end + # Keeps matching nodes and everything that depends on them. + # @return [ProjectGraphBuilder] def dependents_of(pattern, except: nil) filter = Common::RegexFactory.path_matcher(pattern, except:) with_options(options.with(dependents_of: filter)) end + # Groups file nodes by the requested project-relative folder depth. + # @return [ProjectGraphBuilder] def collapse_to_folder_depth(depth) with_options(options.with(collapse: Projection::FolderDepthCollapse.new(depth:))) end + # Groups node names through a regular-expression replacement. + # @return [ProjectGraphBuilder] def collapse_by_pattern(pattern, replacement = '\\1') collapse = Projection::PatternCollapse.from(pattern, replacement) with_options(options.with(collapse:)) end + # Sets the report title. + # @return [ProjectGraphBuilder] def titled(title) with_options(options.with(title:)) end + # Attaches extraction-related CheckOptions to this report. + # @return [ProjectGraphBuilder] def with_check_options(value) self.class.new(project_locator:, options:, check_options: value) end + # @return [GraphReportSnapshot] the immutable queried graph def snapshot graph = ArchUnit::Extraction.extract_graph(project_locator, options: check_options) Projection::SnapshotFactory.create(graph, options) end + # @return [GraphReportSummary] counts for the current snapshot def summary snapshot.summary end diff --git a/lib/archunit/graph/projection/aggregate_edges.rb b/lib/archunit/graph/projection/aggregate_edges.rb index cb23262..3a2c1a0 100644 --- a/lib/archunit/graph/projection/aggregate_edges.rb +++ b/lib/archunit/graph/projection/aggregate_edges.rb @@ -12,7 +12,7 @@ module AggregateEdges def aggregate(edges, collapse:, include_self_dependencies:) groups = Hash.new do |hash, key| - hash[key] = { count: 0, external: false, import_kinds: [] } + hash[key] = { count: 0, external: false, import_kinds: [], evidence: [] } end edges.each do |edge| @@ -30,6 +30,7 @@ def accumulate(group, edge) group[:count] += 1 group[:external] ||= edge.external group[:import_kinds] |= edge.import_kinds + group[:evidence] |= edge.evidence end private_class_method :accumulate @@ -37,7 +38,8 @@ def build_edges(groups) groups.sort_by { |(source, target), _group| [source, target] }.map do |key, group| GraphReportEdge.new( source: key.first, target: key.last, count: group[:count], - external: group[:external], import_kinds: group[:import_kinds] + external: group[:external], import_kinds: group[:import_kinds], + evidence: group[:evidence] ) end.freeze end diff --git a/lib/archunit/graph/projection/graph_query_options.rb b/lib/archunit/graph/projection/graph_query_options.rb index ec453be..9e4d6a0 100644 --- a/lib/archunit/graph/projection/graph_query_options.rb +++ b/lib/archunit/graph/projection/graph_query_options.rb @@ -21,7 +21,7 @@ def self.resolve(value) end # The options bag deliberately mirrors the eight independent fluent modifiers. - # rubocop:disable Metrics/ParameterLists + # rubocop:disable-next Metrics/ParameterLists def initialize( include_external_dependencies: false, include_self_dependencies: false, focus: nil, focus_depth: 1, reachable_from: nil, dependents_of: nil, @@ -37,7 +37,6 @@ def initialize( title = immutable_title(title) super end - # rubocop:enable Metrics/ParameterLists def with(**overrides) self.class.new(**to_h, **overrides) diff --git a/lib/archunit/graph/projection/graph_report_edge.rb b/lib/archunit/graph/projection/graph_report_edge.rb index e404b31..deb6e12 100644 --- a/lib/archunit/graph/projection/graph_report_edge.rb +++ b/lib/archunit/graph/projection/graph_report_edge.rb @@ -1,18 +1,28 @@ # frozen_string_literal: true require_relative '../../common/extraction/import_kind' +require_relative '../../common/extraction/dependency_evidence' module ArchUnit module GraphReporting module Projection # Aggregated dependency evidence in a graph report snapshot. - GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds) do - def initialize(source:, target:, count:, external:, import_kinds: []) + GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds, + :evidence) do + # Keywords mirror this immutable report value. + def initialize( # rubocop:disable Metrics/ParameterLists + source:, target:, count:, external:, import_kinds: [], evidence: [] + ) source = immutable_string(source, :source) target = immutable_string(target, :target) validate_count(count) validate_external(external) import_kinds = immutable_import_kinds(import_kinds) + evidence = Array(evidence).uniq.freeze + unless evidence.all?(Common::Extraction::DependencyEvidence) + raise ArgumentError, 'evidence must contain DependencyEvidence values' + end + super end diff --git a/lib/archunit/graph/rendering/json_renderer.rb b/lib/archunit/graph/rendering/json_renderer.rb index 551c548..e1169c8 100644 --- a/lib/archunit/graph/rendering/json_renderer.rb +++ b/lib/archunit/graph/rendering/json_renderer.rb @@ -26,10 +26,12 @@ def snapshot_hash(snapshot) private_class_method :snapshot_hash def edge_hash(edge) - { + result = { source: edge.source, target: edge.target, count: edge.count, external: edge.external, import_kinds: edge.import_kinds } + result[:evidence] = edge.evidence.map(&:to_h) unless edge.evidence.empty? + result end private_class_method :edge_hash end diff --git a/lib/archunit/layers/fluentapi/layer_definition_builder.rb b/lib/archunit/layers/fluentapi/layer_definition_builder.rb index f70ea4c..a46ca34 100644 --- a/lib/archunit/layers/fluentapi/layer_definition_builder.rb +++ b/lib/archunit/layers/fluentapi/layer_definition_builder.rb @@ -15,10 +15,14 @@ def initialize(architecture, layer_name) freeze end + # Adds a full-path selector to this layer definition. + # @return [LayeredArchitecture] def defined_by(pattern, except: nil) add_filter(Common::RegexFactory.path_matcher(pattern, except:)) end + # Adds a containing-folder selector to this layer definition. + # @return [LayeredArchitecture] def defined_by_folder(pattern, except: nil) add_filter(Common::RegexFactory.folder_matcher(pattern, except:)) end diff --git a/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb b/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb index a54e57f..9c64d2f 100644 --- a/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +++ b/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb @@ -13,10 +13,14 @@ def initialize(architecture, layer_name) freeze end + # Allows only the listed target layers; no targets seals the source layer. + # @return [LayeredArchitecture] def may_only_depend_on_layers(*layer_names) architecture.__send__(:with_allowed_dependencies, layer_name, layer_names) end + # Forbids dependencies on the listed target layers. + # @return [LayeredArchitecture] def may_not_depend_on_layers(*layer_names) if layer_names.empty? raise ArgumentError, 'may_not_depend_on_layers requires at least one layer name' diff --git a/lib/archunit/layers/fluentapi/layered_architecture.rb b/lib/archunit/layers/fluentapi/layered_architecture.rb index b316b60..6b714ef 100644 --- a/lib/archunit/layers/fluentapi/layered_architecture.rb +++ b/lib/archunit/layers/fluentapi/layered_architecture.rb @@ -34,10 +34,14 @@ def initialize( freeze end + # Starts or extends the definition of a named layer. + # @return [LayerDefinitionBuilder] def layer(name) LayerDefinitionBuilder.new(self, validated_layer_name(name)) end + # Selects a defined source layer for a dependency policy. + # @return [LayerDependencyRuleBuilder] def where_layer(name) name = validated_layer_name(name) ensure_defined_layer!(name) diff --git a/lib/archunit/layers/fluentapi/layers.rb b/lib/archunit/layers/fluentapi/layers.rb index 2f4b0c3..308522a 100644 --- a/lib/archunit/layers/fluentapi/layers.rb +++ b/lib/archunit/layers/fluentapi/layers.rb @@ -9,6 +9,8 @@ module Layers module FluentApi module_function + # Starts an immutable named-layer policy. + # @return [LayeredArchitecture] def project_layers(project_locator = nil) LayeredArchitecture.new(project_locator:) end @@ -19,6 +21,9 @@ class << self end end + # Starts an immutable named-layer policy. + # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec + # @return [Layers::FluentApi::LayeredArchitecture] def self.project_layers(project_locator = nil) Layers::FluentApi.project_layers(project_locator) end diff --git a/lib/archunit/metrics/extraction/extract_distance_info.rb b/lib/archunit/metrics/extraction/extract_distance_info.rb index 993e82e..41cb647 100644 --- a/lib/archunit/metrics/extraction/extract_distance_info.rb +++ b/lib/archunit/metrics/extraction/extract_distance_info.rb @@ -30,7 +30,7 @@ def build_distance_infos(project, outgoing, incoming) end private_class_method :build_distance_infos - # rubocop:disable Metrics/AbcSize -- Both directional indexes are built in one graph pass. + # rubocop:disable-next Metrics/AbcSize -- Both directional indexes are built in one graph pass. def coupling_indexes(graph, file_paths) outgoing = Hash.new { |hash, key| hash[key] = [] } incoming = Hash.new { |hash, key| hash[key] = [] } @@ -43,7 +43,6 @@ def coupling_indexes(graph, file_paths) end [outgoing, incoming] end - # rubocop:enable Metrics/AbcSize private_class_method :coupling_indexes end end diff --git a/lib/archunit/metrics/extraction/extract_project_info.rb b/lib/archunit/metrics/extraction/extract_project_info.rb index a262f14..8f8d2e2 100644 --- a/lib/archunit/metrics/extraction/extract_project_info.rb +++ b/lib/archunit/metrics/extraction/extract_project_info.rb @@ -54,7 +54,7 @@ def visitor_counts(visitor) end private_class_method :visitor_counts - # rubocop:disable Metrics/MethodLength -- The state tracks Ruby's =begin/=end comments. + # rubocop:disable-next Metrics/MethodLength -- The state tracks Ruby's =begin/=end comments. def lines_of_code(source) in_block_comment = false source.each_line.count do |line| @@ -70,7 +70,6 @@ def lines_of_code(source) end end end - # rubocop:enable Metrics/MethodLength private_class_method :lines_of_code def source_path(value) diff --git a/lib/archunit/metrics/extraction/metric_info.rb b/lib/archunit/metrics/extraction/metric_info.rb index 5972910..3cc87b7 100644 --- a/lib/archunit/metrics/extraction/metric_info.rb +++ b/lib/archunit/metrics/extraction/metric_info.rb @@ -70,7 +70,7 @@ def initialize(name:, accessed_by: []) end # Static information about one Ruby class declaration. - # rubocop:disable Lint/DataDefineOverride -- `methods` is the established metric vocabulary. + # rubocop:disable-next Lint/DataDefineOverride -- `methods` is the established metric vocabulary. ClassInfo = Data.define(:name, :file_path, :methods, :fields) do include ValueValidation @@ -86,7 +86,6 @@ def identifier "#{file_path}:#{name}" end end - # rubocop:enable Lint/DataDefineOverride # Static counts and class declarations extracted from one Ruby source file. FileInfo = Data.define( @@ -102,7 +101,7 @@ def identifier ) do include ValueValidation - # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- Independent count facts. + # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- Independent count facts. def initialize( path:, lines_of_code:, @@ -131,7 +130,6 @@ def initialize( super end - # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists def identifier path diff --git a/lib/archunit/metrics/extraction/source_metrics_visitor.rb b/lib/archunit/metrics/extraction/source_metrics_visitor.rb index 6cda9b2..66ad23d 100644 --- a/lib/archunit/metrics/extraction/source_metrics_visitor.rb +++ b/lib/archunit/metrics/extraction/source_metrics_visitor.rb @@ -7,7 +7,7 @@ module ArchUnit module Metrics module Extraction # Collects Ruby-specific count and class-cohesion facts from a Prism tree. - # rubocop:disable Metrics/ClassLength -- One visitor owns the state of a single AST walk. + # rubocop:disable-next Metrics/ClassLength -- One visitor owns the state of a single AST walk. class SourceMetricsVisitor < Prism::Visitor IMPORT_METHODS = %i[autoload load require require_relative].freeze ATTRIBUTE_METHODS = %i[attr_accessor attr_reader attr_writer].freeze @@ -82,7 +82,7 @@ def visit_singleton_class_node(node) @singleton_class_depth -= 1 end - # rubocop:disable Metrics/MethodLength -- The ensure keeps nested visitor state balanced. + # rubocop:disable-next Metrics/MethodLength -- The ensure keeps nested visitor state balanced. def visit_def_node(node) active_class = active_class_accumulator unless active_class @@ -97,7 +97,6 @@ def visit_def_node(node) ensure @method_stack.pop if active_class end - # rubocop:enable Metrics/MethodLength INSTANCE_VARIABLE_NODES.each do |visitor_method| define_method(visitor_method) do |node| @@ -271,7 +270,6 @@ def build_field_infos(methods) end end end - # rubocop:enable Metrics/ClassLength private_constant :SourceMetricsVisitor end end diff --git a/lib/archunit/metrics/fluentapi/custom_metric_builder.rb b/lib/archunit/metrics/fluentapi/custom_metric_builder.rb index f283966..20de899 100644 --- a/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +++ b/lib/archunit/metrics/fluentapi/custom_metric_builder.rb @@ -21,6 +21,8 @@ def initialize(scope:, name:, description:, calculation:) super(scope:, metric:) end + # Creates a custom-metric rule retaining the configured description. + # @return [CustomMetricCondition] def should_satisfy(predicate) CustomMetricCondition.new(selection: self, predicate:) end diff --git a/lib/archunit/metrics/fluentapi/metric_report_builder.rb b/lib/archunit/metrics/fluentapi/metric_report_builder.rb index 7151d3e..e8c79f1 100644 --- a/lib/archunit/metrics/fluentapi/metric_report_builder.rb +++ b/lib/archunit/metrics/fluentapi/metric_report_builder.rb @@ -8,6 +8,8 @@ module Metrics module FluentApi # Shared HTML export terminal for one family of scoped metrics. module MetricReportBuilder + # Writes an offline HTML report for all metrics in this family. + # @return [nil] def export_as_html(output_path, options = nil) options = Reporting::MetricsExportOptions.resolve(options).with(output_path:) Reporting::MetricsExporter.export_as_html(metric_report_data, options) diff --git a/lib/archunit/metrics/fluentapi/metric_selection.rb b/lib/archunit/metrics/fluentapi/metric_selection.rb index 83f2a53..1fc2faf 100644 --- a/lib/archunit/metrics/fluentapi/metric_selection.rb +++ b/lib/archunit/metrics/fluentapi/metric_selection.rb @@ -9,6 +9,16 @@ module ArchUnit module Metrics module FluentApi # Lazy selection of one metric over a metrics scope. + # @!method should_be_below(threshold) + # @return [MetricThresholdCondition] + # @!method should_be_above(threshold) + # @return [MetricThresholdCondition] + # @!method should_be(threshold) + # @return [MetricThresholdCondition] + # @!method should_be_below_or_equal(threshold) + # @return [MetricThresholdCondition] + # @!method should_be_above_or_equal(threshold) + # @return [MetricThresholdCondition] class MetricSelection THRESHOLD_METHODS = { should_be_below: :below, @@ -29,8 +39,12 @@ def initialize(scope:, metric:) freeze end - def measure - scope.__send__(:subjects_for, metric.subject_type).map do |subject| + # Evaluates this metric for every selected subject. + # @param options [CheckOptions, nil] dependency-analysis options for coupling metrics + # @return [Array] + def measure(options = nil) + options = Common::FluentApi::CheckOptions.resolve(options) + scope.__send__(:subjects_for, metric.subject_type, options:).map do |subject| MetricMeasurement.new( subject:, metric_name: metric.name, @@ -45,6 +59,8 @@ def measure end end + # Creates a rule from a custom predicate over value and subject. + # @return [MetricPredicateCondition] def should_satisfy(predicate) MetricPredicateCondition.new(selection: self, predicate:) end diff --git a/lib/archunit/metrics/fluentapi/metrics.rb b/lib/archunit/metrics/fluentapi/metrics.rb index aa30e08..4a14f2e 100644 --- a/lib/archunit/metrics/fluentapi/metrics.rb +++ b/lib/archunit/metrics/fluentapi/metrics.rb @@ -9,12 +9,17 @@ module Metrics module FluentApi module_function + # Starts an immutable source-metrics scope. + # @return [MetricsBuilder] def metrics(project_locator = nil) MetricsBuilder.new(project_locator:) end end end + # Starts an immutable source-metrics scope. + # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec + # @return [Metrics::FluentApi::MetricsBuilder] def self.metrics(project_locator = nil) Metrics::FluentApi.metrics(project_locator) end diff --git a/lib/archunit/metrics/fluentapi/metrics_builder.rb b/lib/archunit/metrics/fluentapi/metrics_builder.rb index 2f6cb32..8b33e58 100644 --- a/lib/archunit/metrics/fluentapi/metrics_builder.rb +++ b/lib/archunit/metrics/fluentapi/metrics_builder.rb @@ -23,38 +23,52 @@ def initialize(project_locator: nil, filters: []) freeze end + # Selects metric source files by basename. + # @return [MetricsBuilder] def with_name(pattern, except: nil) with_filter(Common::RegexFactory.filename_matcher(pattern, except:)) end + # Selects metric source files by containing folder. + # @return [MetricsBuilder] def in_folder(pattern, except: nil) with_filter(Common::RegexFactory.folder_matcher(pattern, except:)) end + # Selects metric source files by complete path. + # @return [MetricsBuilder] def in_path(pattern, except: nil) with_filter(Common::RegexFactory.path_matcher(pattern, except:)) end + # Selects classes within the selected source files. + # @return [MetricsBuilder] def for_classes_matching(pattern, except: nil) with_filter(Common::RegexFactory.classname_matcher(pattern, except:)) end + # @return [CountMetricsBuilder] count metric vocabulary def count CountMetricsBuilder.new(self) end + # @return [LCOMMetricsBuilder] cohesion metric vocabulary def lcom LCOMMetricsBuilder.new(self) end + # @return [DistanceMetricsBuilder] dependency-distance metric vocabulary def distance DistanceMetricsBuilder.new(self) end + # Defines a custom numeric calculation over ClassInfo. + # @return [CustomMetricBuilder] def custom_metric(name, description, calculation) CustomMetricBuilder.new(scope: self, name:, description:, calculation:) end + # @return [MetricProjectInfo] extracted files and classes in this scope def analyze project = Extraction.extract_project_info(project_locator) selected_files = project.files.filter_map { |file| selected_file_info(file) } diff --git a/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb b/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb index 9ed130c..4bc3095 100644 --- a/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +++ b/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb @@ -18,6 +18,8 @@ def initialize(scope) freeze end + # Forbids a directed dependency between two named slices. + # @return [ForbiddenSliceDependencyCondition] def contain_dependency(source_slice, target_slice) ForbiddenSliceDependencyCondition.new(scope, source_slice:, target_slice:) end diff --git a/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb b/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb index 7bf029f..5d053d1 100644 --- a/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +++ b/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb @@ -24,18 +24,26 @@ def initialize(scope, options: Assertion::DiagramAdherenceOptions.new) freeze end + # Ignores slices that are absent from the diagram and have no dependency edge. + # @return [PositiveSliceConditionBuilder] def ignoring_orphan_slices copy(options.with(ignore_orphan_slices: true)) end + # Ignores dependencies to external modules during diagram comparison. + # @return [PositiveSliceConditionBuilder] def ignoring_external_slices copy(options.with(ignore_external_slices: true)) end + # Requires the slice graph to match inline PlantUML. + # @return [DiagramSliceCondition] def adhere_to_diagram(text) DiagramSliceCondition.new(scope, DiagramSource.inline(text), options:) end + # Requires the slice graph to match a PlantUML file. + # @return [DiagramSliceCondition] def adhere_to_diagram_in_file(path) DiagramSliceCondition.new(scope, DiagramSource.file(path), options:) end diff --git a/lib/archunit/slices/fluentapi/slice_scope_builder.rb b/lib/archunit/slices/fluentapi/slice_scope_builder.rb index 8ab7011..eeaa1b0 100644 --- a/lib/archunit/slices/fluentapi/slice_scope_builder.rb +++ b/lib/archunit/slices/fluentapi/slice_scope_builder.rb @@ -22,28 +22,40 @@ def initialize(project_locator: nil, projection: Projection.identity) freeze end + # Captures slice names with the `(**)` segment in a glob pattern. + # @return [SliceScopeBuilder] def defined_by(pattern, except: nil) copy(projection: Projection.slice_by_pattern(pattern, except:)) end + # Captures slice names with the first group in a regular expression. + # @return [SliceScopeBuilder] def defined_by_regex(regexp, except: nil) copy(projection: Projection.slice_by_regex(regexp, except:)) end + # Enters the negated slice-rule mood. + # @return [NegativeSliceConditionBuilder] def should_not NegativeSliceConditionBuilder.new(self) end + # Enters the positive slice-rule mood. + # @return [PositiveSliceConditionBuilder] def should PositiveSliceConditionBuilder.new(self) end + # Renders the real slice graph as PlantUML. + # @return [String] def to_plantuml(options = nil) graph = extract_graph(options) edges = Common::Projection.project_edges(graph, projection) Uml::PlantUmlRenderer.render(edges, components: projection.slice_labels(graph)) end + # Writes the real slice graph as PlantUML. + # @return [nil] def export_as_plantuml(output_path, options = nil) graph = extract_graph(options) edges = Common::Projection.project_edges(graph, projection) diff --git a/lib/archunit/slices/fluentapi/slices.rb b/lib/archunit/slices/fluentapi/slices.rb index 96c95bd..2374287 100644 --- a/lib/archunit/slices/fluentapi/slices.rb +++ b/lib/archunit/slices/fluentapi/slices.rb @@ -9,6 +9,8 @@ module Slices module FluentApi module_function + # Starts an immutable slice-rule sentence. + # @return [SliceScopeBuilder] def project_slices(project_locator = nil) SliceScopeBuilder.new(project_locator:) end @@ -19,6 +21,9 @@ class << self end end + # Starts an immutable slice-rule sentence. + # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec + # @return [Slices::FluentApi::SliceScopeBuilder] def self.project_slices(project_locator = nil) Slices::FluentApi.project_slices(project_locator) end diff --git a/lib/archunit/testing.rb b/lib/archunit/testing.rb index d1651c2..fbca7a4 100644 --- a/lib/archunit/testing.rb +++ b/lib/archunit/testing.rb @@ -14,10 +14,14 @@ module ArchUnit module Testing module_function + # Formats structured violations for a terminal or test failure. + # @return [String] def format_violations(violations, color: nil) ResultFactory.from_violations(violations, color:).message end + # Converts a rule check into a framework-neutral TestResult. + # @return [TestResult] def result_for(rule, options = nil, expected_to_pass: true) unless rule.is_a?(Common::FluentApi::Checkable) raise ArgumentError, 'rule must implement Checkable' @@ -30,6 +34,8 @@ def result_for(rule, options = nil, expected_to_pass: true) end end + # Formats structured violations for a terminal or test failure. + # @return [String] def self.format_violations(violations, color: nil) Testing.format_violations(violations, color:) end diff --git a/lib/archunit/testing/assert_passes.rb b/lib/archunit/testing/assert_passes.rb index f18c954..7120f1a 100644 --- a/lib/archunit/testing/assert_passes.rb +++ b/lib/archunit/testing/assert_passes.rb @@ -10,6 +10,8 @@ module ArchUnit module Testing module_function + # Raises AssertionFailure when a rule returns violations. + # @return [nil] def assert_passes(rule, options = nil) result = result_for(rule, options) return if result.passed? @@ -18,6 +20,8 @@ def assert_passes(rule, options = nil) end end + # Raises AssertionFailure when a rule returns violations. + # @return [nil] def self.assert_passes(rule, options = nil) Testing.assert_passes(rule, options) end diff --git a/lib/archunit/testing/rspec_adapter.rb b/lib/archunit/testing/rspec_adapter.rb index bf9a093..64035d3 100644 --- a/lib/archunit/testing/rspec_adapter.rb +++ b/lib/archunit/testing/rspec_adapter.rb @@ -13,11 +13,10 @@ def matches?(rule) end # RSpec's matcher protocol fixes this method name. - # rubocop:disable Naming/PredicatePrefix + # rubocop:disable-next Naming/PredicatePrefix def does_not_match?(rule) matches_expectation?(rule, expected_to_pass: false) end - # rubocop:enable Naming/PredicatePrefix def failure_message result.message diff --git a/scripts/check_dependency_analysis.rb b/scripts/check_dependency_analysis.rb new file mode 100644 index 0000000..ce45d87 --- /dev/null +++ b/scripts/check_dependency_analysis.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'tmpdir' +require 'archunit' + +# Also run after installing the built gem, outside Bundler's source checkout. +Dir.mktmpdir('archunit-installed-analysis') do |root| + File.write(File.join(root, 'Gemfile'), "raise 'must never execute project source'\n") + %w[api retrieval].each { |name| FileUtils.mkdir_p(File.join(root, 'app', name)) } + request = File.join(root, 'app/api/request.rb') + File.write(File.join(root, 'app/retrieval/store.rb'), 'module Retrieval; class Store; end; end') + File.write(request, 'module Api; class Request; Retrieval::Store.new; end; end') + options = ArchUnit::CheckOptions.new(dependency_analysis: :imports_and_constants) + rule = ArchUnit.project_files(root).in_path('app/api/**') + .should_not.depend_on_files.in_path('app/retrieval/**') + abort 'import-only mode changed' unless rule.check.empty? + abort 'constant dependency missing' unless rule.check(options).length == 1 + allowed = ArchUnit.project_files(root).in_path('app/api/**') + .should.depend_on_files.in_path('app/retrieval/**') + ArchUnit.assert_passes(allowed, options) + payload = JSON.parse(ArchUnit.project_graph(root).with_check_options(options).to_json) + evidence = payload.fetch('edges').first.fetch('evidence').first + abort 'constant evidence missing' unless evidence['name'] == 'Retrieval::Store' + + File.write(request, "path = '../retrieval/store'\nrequire_relative path\n") + refreshed = ArchUnit::CheckOptions.new(clear_cache: true) + abort 'local-string dependency missing' unless rule.check(refreshed).length == 1 + ArchUnit.assert_passes(allowed) + puts 'Installed dependency analysis: constant and local-string reproducers passed.' +ensure + ArchUnit.clear_graph_cache +end diff --git a/scripts/check_docs.rb b/scripts/check_docs.rb index 91343df..1ba3dc8 100644 --- a/scripts/check_docs.rb +++ b/scripts/check_docs.rb @@ -6,7 +6,8 @@ ROOT = Pathname.new(__dir__).join('..').expand_path OUTPUT = ROOT.join('docs') REQUIRED_FILES = %w[ - index.html class_list.html css/archunit.css .nojekyll robots.txt sitemap.xml + index.html file.API.html class_list.html css/archunit.css assets/logo-rounded.png + assets/social-preview.png .nojekyll robots.txt sitemap.xml ].freeze def internal_target(page, href) @@ -40,4 +41,20 @@ def internal_target(page, href) raise "Broken documentation links:\n#{broken.join("\n")}" unless broken.empty? -puts "Validated #{pages.length} documentation pages and their internal links" +guide = OUTPUT.join('index.html').binread.force_encoding(Encoding::UTF_8) +unrendered = { + 'GitHub badge Markdown' => '[![', + 'backtick code fence' => "#{96.chr * 3}ruby" +}.filter_map { |label, marker| label if guide.include?(marker) } +raise "Documentation guide contains unrendered #{unrendered.join(' and ')}" unless unrendered.empty? + +api_guide = OUTPUT.join('file.API.html').binread.force_encoding(Encoding::UTF_8) +required_api_terms = %w[ + project_files project_layers project_slices project_graph metrics CheckOptions LoggingOptions +].freeze +missing_api_terms = required_api_terms.reject { |term| api_guide.include?(term) } +unless missing_api_terms.empty? + raise "API guide is missing public entry points: #{missing_api_terms.join(', ')}" +end + +puts "Validated #{pages.length} documentation pages, public API coverage, and internal links" diff --git a/scripts/prepare_docs.rb b/scripts/prepare_docs.rb index ce19c85..039763e 100644 --- a/scripts/prepare_docs.rb +++ b/scripts/prepare_docs.rb @@ -1,25 +1,45 @@ # frozen_string_literal: true +require 'fileutils' require 'pathname' ROOT = Pathname.new(__dir__).join('..').expand_path OUTPUT = ROOT.join('docs') STYLESHEET = OUTPUT.join('css', 'archunit.css') +LOGO = OUTPUT.join('assets', 'logo-rounded.png') +SOCIAL_PREVIEW = OUTPUT.join('assets', 'social-preview.png') SITE_URL = 'https://lukasniessen.github.io/ArchUnitRuby/' SOURCE_URL = 'https://github.com/LukasNiessen/ArchUnitRuby/blob/main/' +SOURCE_DOCUMENTS = %w[ + LICENSE AGENTS.md CONTRIBUTING.md SECURITY.md SUPPORT.md CODE_OF_CONDUCT.md +].freeze NAVIGATION = <<~HTML HTML +PAGE_HEAD = <<~HTML + + + + + + + + + + + +HTML def relative_href(target, page) target.relative_path_from(page.dirname).to_s.tr('\\', '/') @@ -27,26 +47,29 @@ def relative_href(target, page) def navigation(page) guide = relative_href(OUTPUT.join('index.html'), page) - api = relative_href(OUTPUT.join('class_list.html'), page) - format(NAVIGATION, guide: guide, api: api) + api_guide = relative_href(OUTPUT.join('file.API.html'), page) + reference = relative_href(OUTPUT.join('class_list.html'), page) + logo = relative_href(LOGO, page) + format(NAVIGATION, guide:, api_guide:, reference:, logo:) end def page_head(page) stylesheet = relative_href(STYLESHEET, page) - <<~HTML - - - - - HTML + logo = relative_href(LOGO, page) + format(PAGE_HEAD, site_url: SITE_URL, logo:, stylesheet:) +end + +def rewrite_source_links(content) + SOURCE_DOCUMENTS.each do |document| + content.gsub!("href=\"#{document}\"", "href=\"#{SOURCE_URL}#{document}\"") + end end def prepare_page(path) page = Pathname.new(path) content = page.binread.force_encoding(Encoding::UTF_8) - content.gsub!('href="LICENSE"', "href=\"#{SOURCE_URL}LICENSE\"") - content.gsub!('href="AGENTS.md"', "href=\"#{SOURCE_URL}AGENTS.md\"") + rewrite_source_links(content) if page.basename.to_s.match?(/\A(?:class|method|file)_list\.html\z/) content.sub!('', '') end @@ -55,7 +78,12 @@ def prepare_page(path) page.binwrite(content) end +FileUtils.mkdir_p(LOGO.dirname) +FileUtils.cp(ROOT.join('assets', 'logo-rounded.png'), LOGO) +FileUtils.cp(ROOT.join('assets', 'social-preview.png'), SOCIAL_PREVIEW) + raise 'YARD did not generate the documentation stylesheet' unless STYLESHEET.file? +raise 'YARD did not copy the documentation logo' unless LOGO.file? pages = Dir[OUTPUT.join('**', '*.html')] raise 'YARD did not generate any documentation pages' if pages.empty? diff --git a/spec/common/extraction/dependency_evidence_spec.rb b/spec/common/extraction/dependency_evidence_spec.rb new file mode 100644 index 0000000..d81d610 --- /dev/null +++ b/spec/common/extraction/dependency_evidence_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe ArchUnit::DependencyEvidence do + def evidence(**attributes) + described_class.new( + source: 'app/api.rb', kind: :constant_reference, name: 'Store', line_number: 3, + **attributes + ) + end + + it 'owns immutable source strings and validates source and target spans' do + name = +'Store' + value = evidence(source: 'app\\api.rb', name:, end_line: 5, target_line_number: 2) + name.replace('Other') + + expect(value).to have_attributes(source: 'app/api.rb', name: 'Store', end_line: 5) + expect(value.name).to be_frozen + expect(value.source).to be_frozen + expect(value).to be_frozen + end + + it 'rejects invalid evidence identities and line spans' do + expect { evidence(kind: :dynamic) }.to raise_error(ArgumentError, /valid dependency kind/) + expect { evidence(source: nil) }.to raise_error(ArgumentError, /requires a source/) + expect { evidence(name: nil) }.to raise_error(ArgumentError, /requires a source/) + [0, nil, '3'].each do |line| + expect { evidence(line_number: line) }.to raise_error(ArgumentError, /source line numbers/) + end + expect { evidence(end_line: 2) }.to raise_error(ArgumentError, /source line numbers/) + expect { evidence(target_line_number: 0) }.to raise_error(ArgumentError, /source line numbers/) + end + + it 'requires typed evidence on raw and report edges' do + expect do + ArchUnit::Edge.new(source: 'a.rb', target: 'b.rb', external: false, evidence: ['untyped']) + end.to raise_error(ArgumentError, /DependencyEvidence/) + expect do + ArchUnit::GraphReportEdge.new( + source: 'a.rb', target: 'b.rb', external: false, count: 1, evidence: ['untyped'] + ) + end.to raise_error(ArgumentError, /DependencyEvidence/) + end +end diff --git a/spec/common/extraction/import_kind_spec.rb b/spec/common/extraction/import_kind_spec.rb index f38c593..1ca1bce 100644 --- a/spec/common/extraction/import_kind_spec.rb +++ b/spec/common/extraction/import_kind_spec.rb @@ -6,7 +6,8 @@ :require, :require_relative, :autoload, - :load + :load, + :constant_reference ) end diff --git a/spec/common/fluentapi/check_options_spec.rb b/spec/common/fluentapi/check_options_spec.rb index 565fbf5..a6eea37 100644 --- a/spec/common/fluentapi/check_options_spec.rb +++ b/spec/common/fluentapi/check_options_spec.rb @@ -8,7 +8,8 @@ allow_empty_tests: false, logging: nil, clear_cache: false, - load_paths: [] + load_paths: [], + dependency_analysis: :imports ) expect(options.allow_empty_tests?).to be(false) expect(options.clear_cache?).to be(false) @@ -57,4 +58,12 @@ expect(ArchUnit::CheckOptions).to equal(described_class) expect(ArchUnit::LoggingOptions).to equal(ArchUnit::Common::Logging::LoggingOptions) end + + it 'supports explicit constant analysis and rejects unknown analysis modes' do + expect(described_class.new).not_to be_constant_references + expanded = described_class.new(dependency_analysis: :imports_and_constants) + expect(expanded).to be_constant_references + expect { described_class.new(dependency_analysis: :dynamic) } + .to raise_error(ArgumentError, /dependency_analysis/) + end end diff --git a/spec/common/pattern_spec.rb b/spec/common/pattern_spec.rb index 60e84ad..31ccc6d 100644 --- a/spec/common/pattern_spec.rb +++ b/spec/common/pattern_spec.rb @@ -29,6 +29,12 @@ def matches?(pattern, value) expect(matches?('lib/**/*.rb', 'app/service.rb')).to be(false) end + it 'lets a trailing double star include the named folder and its descendants' do + expect(matches?('app/api/**', 'app/api')).to be(true) + expect(matches?('app/api/**', 'app/api/internal')).to be(true) + expect(matches?('app/api/**', 'app/apis')).to be(false) + end + it 'supports question marks and character classes within a segment' do expect(matches?('service?.[rR][bB]', 'service1.rb')).to be(true) expect(matches?('service?.[rR][bB]', 'service12.rb')).to be(false) diff --git a/spec/documentation/readme_quickstart_spec.rb b/spec/documentation/readme_quickstart_spec.rb new file mode 100644 index 0000000..f76f2da --- /dev/null +++ b/spec/documentation/readme_quickstart_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'README quickstart' do + around do |example| + Dir.mktmpdir('archunit-readme-quickstart') do |directory| + @project_root = directory + File.write(File.join(directory, 'Gemfile'), '') + write_file('app/database/order_repository.rb', "class OrderRepository; end\n") + ArchUnit.clear_graph_cache + example.run + ArchUnit.clear_graph_cache + end + end + + def write_file(relative_path, content) + path = File.join(@project_root, relative_path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + def readme_rule + ArchUnit.project_files + .in_folder('app/api/**') + .should_not.depend_on_files + .in_folder('app/database/**') + end + + it 'runs the documented first rule against files directly inside each folder' do + write_file('app/api/orders.rb', "class Orders; end\n") + + Dir.chdir(@project_root) do + expect(readme_rule).to pass + end + end + + it 'makes the documented first rule fail for a forbidden dependency' do + write_file( + 'app/api/orders.rb', + "require_relative '../database/order_repository'\nclass Orders; end\n" + ) + + Dir.chdir(@project_root) do + expect(readme_rule.check).to contain_exactly( + an_instance_of(ArchUnit::FileDependencyViolation) + ) + end + end +end diff --git a/spec/extraction/constant_dependencies_spec.rb b/spec/extraction/constant_dependencies_spec.rb new file mode 100644 index 0000000..be1d8d8 --- /dev/null +++ b/spec/extraction/constant_dependencies_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'pathname' +require 'tmpdir' + +RSpec.describe ArchUnit::Extraction, 'constant dependencies' do + let(:options) { ArchUnit::CheckOptions.new(dependency_analysis: :imports_and_constants) } + + around do |example| + Dir.mktmpdir('archunit-constants') do |directory| + @root = Pathname.new(directory).realpath + described_class.clear_graph_cache + example.run + described_class.clear_graph_cache + end + end + + def source(path, contents) + file = @root.join(path) + FileUtils.mkdir_p(file.dirname) + file.write(contents) + end + + def dependencies(**keywords) + described_class.extract_graph(@root, options:, **keywords).reject do |edge| + edge.source == edge.target + end + end + + def targets(path = 'app/api/request.rb') + dependencies.select { |edge| edge.source == path }.map(&:target) + end + + it 'resolves the exact autoloaded application example without executing source' do + source('app/api/request.rb', <<~RUBY) + module Api + class Request + def call = Retrieval::Store.new + end + end + raise 'source executed' + RUBY + source('app/retrieval/store.rb', 'module Retrieval; class Store; end; end') + + expect(targets).to eq(['app/retrieval/store.rb']) + expect(dependencies.first.evidence).to contain_exactly( + have_attributes(name: 'Retrieval::Store', line_number: 3, target_line_number: 1) + ) + expect(described_class.extract_graph(@root).reject { |edge| edge.source == edge.target }) + .to be_empty + end + + it 'distinguishes lexical nesting from a compact namespace and root qualification' do + source('lib/outer.rb', 'module Outer; TOKEN = 1; end') + source('lib/root.rb', 'TOKEN = 2') + source('lib/nested.rb', 'module Outer; module Nested; TOKEN; end; end') + source('lib/compact.rb', 'module Outer::Compact; TOKEN; ::TOKEN; Outer::TOKEN; end') + + expect(targets('lib/nested.rb')).to eq(['lib/outer.rb']) + expect(targets('lib/compact.rb')).to eq(%w[lib/root.rb lib/outer.rb]) + end + + it 'does not fall back to another namespace when a nearer head shadows it' do + source('lib/global.rb', 'module Store; class Item; end; end') + source('lib/shadow.rb', 'module Api; module Store; end; end') + source('app/api/request.rb', 'module Api; Store::Item; end') + + expect(targets).to be_empty + end + + it 'resolves nested relative compact declarations using the known parent namespace' do + source('lib/namespaces.rb', 'module Outer; module Inner; end; end') + source('lib/item.rb', 'module Outer; class Inner::Item; end; end') + source('app/api/request.rb', 'Outer::Inner::Item') + + expect(targets).to eq(['lib/item.rb']) + end + + it 'records superclass and include, prepend, and extend references' do + source('lib/base.rb', 'class Base; end') + source('lib/mix.rb', 'module Mix; end') + source('app/api/request.rb', <<~RUBY) + class Request < ::Base + include ::Mix + prepend ::Mix + extend ::Mix + end + RUBY + + expect(targets).to eq(%w[lib/base.rb lib/mix.rb]) + mix = dependencies.find { |edge| edge.target == 'lib/mix.rb' } + expect(mix.evidence.map(&:line_number)).to eq([2, 3, 4]) + end + + it 'stabilizes dependent relative compact declarations before resolving references' do + source('lib/namespaces.rb', 'module Outer; module Inner; module Deep; end; end; end') + source('lib/namespace.rb', 'module Outer; module Inner::Deep; module Items; end; end; end') + source('lib/item.rb', 'module Outer; module Inner::Deep; class Items::Item; end; end; end') + source('app/api/request.rb', 'Outer::Inner::Deep::Items::Item') + + expect(targets).to eq(['lib/item.rb']) + end + + it 'avoids guessed lexical ownership in singleton and dynamically changed namespaces' do + source('lib/item.rb', 'class Item; end; module Store; class Item; end; end') + source('app/api/request.rb', <<~RUBY) + module Api + const_set(:Store, build_store) + Store::Item.new + class << self + Item = build_item + Item.new + end + end + RUBY + + expect(targets).to be_empty + end + + it 'falls back to imports when dependent namespace owners exceed the bounded index passes' do + source('lib/known.rb', 'class Known; end') + declarations = ['module Root', 'module Branch; end'] + 10.times { declarations.push('module Branch::Nested', 'module Branch; end') } + declarations << '::Known.new' + declarations.concat(Array.new(11, 'end')) + source('app/api/request.rb', declarations.join("\n")) + profile = ArchUnit::Extraction::ExtractionProfile.new + + expect(dependencies(profile:)).to be_empty + expect(profile.counters[:unresolved_constant_references]).to eq(1) + end + + it 'resolves root-qualified writes and uses inside singleton bodies' do + source('lib/item.rb', 'class ::Item; end; ::VALUE = 1') + source('app/api/request.rb', 'class Request; class << self; ::Item.new; end; end; ::VALUE') + + expect(targets).to eq(['lib/item.rb']) + expect(dependencies.first.evidence.map(&:name)).to eq(%w[::Item ::VALUE]) + end + + it 'does not guess inherited constants or substitute a colliding root constant' do + source('lib/base.rb', 'class Base; TOKEN = 1; end') + source('lib/root.rb', 'TOKEN = 2') + source('app/api/request.rb', 'class Request < Base; TOKEN; end') + + expect(targets).to eq(['lib/base.rb']) + end + + it 'ignores declaration names, comments, string text, and same-file uses' do + source('lib/item.rb', 'class Item; end') + source('app/api/request.rb', <<~RUBY) + # Item.new + text = 'Item.new' + module Api + class Local; end + Local.new + end + RUBY + + expect(dependencies).to be_empty + end + + it 'treats reopened definitions in different files as ambiguous, including local reopenings' do + source('lib/one.rb', 'class Shared; end') + source('lib/two.rb', 'class Shared; Shared.new; end') + source('app/api/request.rb', 'Shared.new; Missing.new') + profile = ArchUnit::Extraction::ExtractionProfile.new + + expect(dependencies(profile:)).to be_empty + expect(profile.counters).to include( + ambiguous_constant_references: 2, unresolved_constant_references: 1 + ) + end + + it 'models a constant alias as a definition with a dependency on its right hand side' do + source('lib/item.rb', 'class Item; end') + source('lib/alias.rb', 'Alias = Item') + source('app/api/request.rb', 'Alias.new') + + expect(targets).to eq(['lib/alias.rb']) + expect(targets('lib/alias.rb')).to eq(['lib/item.rb']) + end + + it 'merges imports and uses into one edge while preserving both source locations' do + source('lib/item.rb', 'class Item; end') + source('app/api/request.rb', "require_relative '../../lib/item'\nItem.new\n") + + expect(dependencies).to contain_exactly( + have_attributes( + import_kinds: %i[require_relative constant_reference], + evidence: contain_exactly( + have_attributes(kind: :require_relative, line_number: 1), + have_attributes(kind: :constant_reference, name: 'Item', line_number: 2) + ) + ) + ) + end + + it 'shares cached indices across rules and refreshes changed definitions on clear_cache' do + source('lib/item.rb', 'class Item; end') + source('app/api/request.rb', 'Item.new') + first = described_class.extract_graph(@root, options:) + expect(described_class.extract_graph(@root, options:)).to equal(first) + expect(described_class.extract_graph(@root)).not_to equal(first) + source('lib/item.rb', 'class Renamed; end') + expect(targets).to eq(['lib/item.rb']) + + refreshed = described_class.extract_graph(@root, options: options.with(clear_cache: true)) + expect(refreshed.reject { |edge| edge.source == edge.target }).to be_empty + end + + it 'parses each source only once and reuses the graph on a repeated check' do + source('lib/item.rb', 'class Item; end') + source('app/api/request.rb', 'Item.new') + expect(Prism).to receive(:parse).with(anything, filepath: anything).twice.and_call_original + + expect(targets).to eq(['lib/item.rb']) + expect(targets).to eq(['lib/item.rb']) + end + + it 'uses the same excluded source scope for definitions and references' do + source('lib/item.rb', 'class Item; end') + source('app/api/request.rb', 'Item.new') + filtered = described_class.extract_graph(@root, options:, exclude_patterns: ['lib']) + expect(filtered.reject { |edge| edge.source == edge.target }).to be_empty + end +end diff --git a/spec/extraction/dependency_analysis_integration_spec.rb b/spec/extraction/dependency_analysis_integration_spec.rb new file mode 100644 index 0000000..12dda04 --- /dev/null +++ b/spec/extraction/dependency_analysis_integration_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'minitest' +require 'pathname' +require 'tmpdir' + +RSpec.describe 'expanded dependency analysis through the public API' do + let(:options) { ArchUnit::CheckOptions.new(dependency_analysis: :imports_and_constants) } + + around do |example| + Dir.mktmpdir('archunit-dependency-analysis') do |directory| + @root = Pathname.new(directory).realpath + @root.join('Gemfile').write("raise 'Gemfile executed'") + source('app/retrieval/store.rb', 'module Retrieval; class Store; end; end') + source('app/api/request.rb', 'module Api; class Request; Retrieval::Store.new; end; end') + ArchUnit.clear_graph_cache + example.run + ArchUnit.clear_graph_cache + end + end + + def source(path, contents) + file = @root.join(path) + FileUtils.mkdir_p(file.dirname) + file.write(contents) + end + + def file_rule(mood = :should_not) + ArchUnit.project_files(@root).in_path('app/api/**') + .public_send(mood).depend_on_files.in_path('app/retrieval/**') + end + + it 'reports the forbidden dependency exactly once and accepts its permitted counterpart' do + expect(file_rule.check).to be_empty + expect(file_rule.check(options)).to contain_exactly( + have_attributes(dependency: have_attributes(target_label: 'app/retrieval/store.rb')) + ) + expect { ArchUnit.assert_passes(file_rule(:should), options) }.not_to raise_error + end + + it 'recognizes the local-string reproducer through the default public file rules' do + source('app/api/request.rb', "path = '../retrieval/store'\nrequire_relative path\n") + + expect(file_rule.check.length).to eq(1) + expect(file_rule(:should)).to pass + expect(file_rule.check(options).first.dependency.cumulated_edges.first).to have_attributes( + import_kinds: [:require_relative], + evidence: contain_exactly(have_attributes(line_number: 2, name: '../retrieval/store')) + ) + expect(file_rule.check.first.dependency.cumulated_edges.first).to eq( + ArchUnit::Edge.new( + source: 'app/api/request.rb', target: 'app/retrieval/store.rb', external: false, + import_kinds: [:require_relative] + ) + ) + end + + it 'detects both a constant-only cycle and a local-string-only cycle' do + source('app/retrieval/store.rb', 'module Retrieval; class Store; Api::Request.new; end; end') + rule = ArchUnit.project_files(@root).should.have_no_cycles + expect(rule.check).to be_empty + expect(rule.check(options).length).to eq(1) + + source('app/api/request.rb', "path = '../retrieval/store'\nrequire_relative path\n") + source('app/retrieval/store.rb', "path = '../api/request'\nrequire_relative path\n") + expect(rule.check(ArchUnit::CheckOptions.new(clear_cache: true)).length).to eq(1) + end + + it 'feeds identical constant edges into layer, slice, diagram, and graph consumers' do + layers = ArchUnit.project_layers(@root) + .layer('api').defined_by('app/api/**') + .layer('retrieval').defined_by('app/retrieval/**') + .where_layer('api').may_not_depend_on_layers('retrieval') + slices = ArchUnit.project_slices(@root).defined_by('app/(**)/') + expect(layers.check(options).length).to eq(1) + expect(slices.should_not.contain_dependency('api', 'retrieval').check(options).length).to eq(1) + diagram = slices.to_plantuml(options) + expect(diagram).to include('[api] --> [retrieval]') + expect(slices.should.adhere_to_diagram(diagram).check(options)).to be_empty + + report = ArchUnit.project_graph(@root).with_check_options(options) + payload = JSON.parse(report.to_json) + expect(payload.fetch('edges')).to contain_exactly( + include( + 'source' => 'app/api/request.rb', 'target' => 'app/retrieval/store.rb', + 'import_kinds' => ['constant_reference'], + 'evidence' => [include('name' => 'Retrieval::Store', 'line_number' => 1)] + ) + ) + expect(report.to_csv).to include('constant_reference') + expect(report.to_mermaid).to include('-->') + end + + it 'uses the same analysis through the RSpec and Minitest adapters' do + allowed = file_rule(:should) + expect(allowed).to pass(options) + ArchUnit::Testing::MinitestAdapter.install! + context = Class.new do + include Minitest::Assertions + + attr_accessor :assertions + end.new + context.assertions = 0 + expect(context.assert_passes(allowed, options)).to be(true) + expect { context.assert_passes(file_rule, options) }.to raise_error(Minitest::Assertion) + + source('app/api/request.rb', "path = '../retrieval/store'\nrequire_relative path\n") + ArchUnit.clear_graph_cache + expect(context.assert_passes(file_rule(:should))).to be(true) + expect { context.assert_passes(file_rule) }.to raise_error(Minitest::Assertion) + end + + it 'uses constant edges for coupling measurements and metric threshold checks' do + metric = ArchUnit.metrics(@root).in_path('app/api/**').distance.instability + + expect(metric.measure.first.value).to eq(0.0) + expect(metric.measure(options).first.value).to eq(1.0) + expect(metric.should_be(1.0).check(options)).to be_empty + end +end diff --git a/spec/extraction/extract_imports_spec.rb b/spec/extraction/extract_imports_spec.rb index 3ac1d3a..b54c3aa 100644 --- a/spec/extraction/extract_imports_spec.rb +++ b/spec/extraction/extract_imports_spec.rb @@ -53,6 +53,78 @@ def extract(relative_path = 'lib/example.rb') ) end + it 'resolves supported imports from preceding local literal assignments' do + create_file('lib/example.rb', <<~RUBY) + required = 'sample/widget' + relative = './support' + autoloaded = 'sample/plugin' + loaded = 'scripts/boot.rb' + require required + require_relative relative + autoload :Plugin, autoloaded + load loaded + RUBY + create_file('lib/support.rb') + create_file('lib/sample/widget.rb') + create_file('lib/sample/plugin.rb') + create_file('scripts/boot.rb') + + expect(extract.map { |item| [item.module_name, item.import_kind] }).to eq( + [ + ['sample/widget', :require], + ['./support', :require_relative], + ['sample/plugin', :autoload], + ['scripts/boot.rb', :load] + ] + ) + end + + it 'uses assignment order and invalidates reassigned or escaped values' do + create_file('lib/example.rb', <<~RUBY) + require_relative later + later = 'support' + path = 'first' + path = 'second' + require_relative path + path = ENV.fetch('DYNAMIC_PATH') + require_relative path + escaped = 'support' + consume(escaped) + require_relative escaped + RUBY + + expect(extract.map(&:module_name)).to eq(['second']) + end + + it 'does not retain stale string values through aliases, uncertain flow, or scope boundaries' do + create_file('lib/example.rb', <<~RUBY) + path = 'support' + alias_path = path + alias_path.replace('other') + require_relative path + + branch_path = 'support' + if condition + branch_path = 'first' + else + branch_path = 'second' + end + require_relative branch_path + + outer = 'support' + def load_dependency + require_relative outer + end + + block_path = 'support' + items.each do + require_relative block_path + end + RUBY + + expect(extract).to be_empty + end + it 'uses Ruby resolution rules for relative, project, standard-library, and load targets' do create_file('lib/example.rb', <<~RUBY) require_relative 'support' @@ -74,6 +146,117 @@ def extract(relative_path = 'lib/example.rb') expect(imports.fetch('missing_archunit_dependency').resolved_path).to be_nil end + it 'invalidates compound assignments, container aliases, block writes, and reflection' do + create_file('lib/example.rb', <<~RUBY) + path = 'support' + path += '/other' + require_relative path + path = 'support' + path ||= 'other' + require_relative path + path = 'support' + path, other = dynamic_values + require_relative path + path = 'support' + aliases = [path] + require_relative path + path = 'support' + @escaped = path + require_relative path + path = 'support' + items.each { path = 'other' } + require_relative path + path = 'support' + eval('path = "other"') + require_relative path + RUBY + + expect(extract).to be_empty + end + + it 'does not infer local strings inside or across unsupported control flow joins' do + create_file('lib/example.rb', <<~RUBY) + if condition + path = 'wrong_branch' + else + require_relative path + end + path = 'support' + condition && (path = 'other') + require_relative path + path = 'support' + while condition + path = 'other' + end + require_relative path + path = 'support' + begin + path = 'other' + rescue + require_relative path + end + require_relative path + require_relative 'literal_still_known' + RUBY + + expect(extract.map(&:module_name)).to eq(['literal_still_known']) + end + + it 'tracks independent literals inside methods, classes, and blocks and preserves ignores' do + create_file('lib/example.rb', <<~RUBY) + def boot + path = 'method_path' + require path + end + module Application + path = 'module_path' + load path + end + path = 'ignored_path' + require path # archunit: ignore ignored_path + tasks.each do + path = 'block_path' + require_relative path + end + RUBY + + expect(extract.map(&:module_name)).to eq(%w[method_path module_path block_path]) + end + + it 'does not infer strings after a closure or binding can mutate future assignments' do + create_file('lib/example.rb', <<~RUBY) + path = nil + mutate = -> { path.replace('other') } + path = 'support' + mutate.call + require_relative path + def separate_scope + captured = binding + path = 'support' + captured.local_variable_set(:path, 'other') + require_relative path + end + RUBY + + expect(extract).to be_empty + end + + it 'does not propagate locals into singleton bodies or through defined? and regexp writes' do + create_file('lib/example.rb', <<~RUBY) + path = 'support' + class << self + require_relative path + end + defined?(path = 'other') + require_relative path + path = 'support' + /(?.+)/ =~ input + require_relative path + RUBY + + expect(extract).to be_empty + end + it 'does not execute imported source while resolving it' do create_file('lib/example.rb', "require_relative 'explosive'\n") create_file('lib/explosive.rb', "raise 'executed'\n") @@ -82,7 +265,7 @@ def extract(relative_path = 'lib/example.rb') expect(extract.first.resolved_path).to end_with('/lib/explosive.rb') end - it 'ignores dynamic arguments, invalid arity, and custom require methods' do + it 'infers a local literal while ignoring other dynamic arguments and unsupported calls' do create_file('lib/example.rb', <<~'RUBY') name = 'json' require name @@ -93,7 +276,9 @@ def extract(relative_path = 'lib/example.rb') require 'one', 'two' RUBY - expect(extract).to be_empty + expect(extract.map { |item| [item.module_name, item.import_kind] }).to eq( + [['json', :require], ['json', :autoload]] + ) end it 'ignores empty and null-byte dependency names without aborting the file' do diff --git a/spec/extraction/extraction_profiling_integration_spec.rb b/spec/extraction/extraction_profiling_integration_spec.rb index 384eaf7..8020680 100644 --- a/spec/extraction/extraction_profiling_integration_spec.rb +++ b/spec/extraction/extraction_profiling_integration_spec.rb @@ -61,4 +61,14 @@ def create_file(relative_path, contents = '# fixture') expect { described_class.extract_graph(@project_root, profile: Object.new) } .to raise_error(ArgumentError, /ExtractionProfile/) end + + it 'counts unresolved runtime import paths without executing them' do + create_file('lib/source.rb', "path = ENV.fetch('UNKNOWN_PLUGIN')\nrequire path\n") + profile = ArchUnit::Extraction::ExtractionProfile.new + + graph = described_class.extract_graph(@project_root, profile:) + + expect(graph.length).to eq(1) + expect(profile.counters).to include(imports: 0, unresolved_imports: 1) + end end diff --git a/spec/metrics/calculation/distance_spec.rb b/spec/metrics/calculation/distance_spec.rb index 8841932..6437739 100644 --- a/spec/metrics/calculation/distance_spec.rb +++ b/spec/metrics/calculation/distance_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe ArchUnit::DistanceMetrics do - # rubocop:disable Metrics/ParameterLists -- Named fixture dimensions keep formulas readable. + # rubocop:disable-next Metrics/ParameterLists -- Named fixture dimensions keep formulas readable. def distance_info( types: 1, abstract_types: 0, incoming: 0, outgoing: 0, files: 1, lines: 0 ) @@ -15,7 +15,6 @@ def distance_info( efferent_coupling: outgoing, project_file_count: files ) end - # rubocop:enable Metrics/ParameterLists def values_for(info) described_class::CALCULATIONS.to_h do |name, _calculation| diff --git a/spec/metrics/fluentapi/metrics_spec.rb b/spec/metrics/fluentapi/metrics_spec.rb index a50b80c..3e8b133 100644 --- a/spec/metrics/fluentapi/metrics_spec.rb +++ b/spec/metrics/fluentapi/metrics_spec.rb @@ -9,7 +9,7 @@ def write_source(root, relative_path, source) File.write(path, source) end - # rubocop:disable Metrics/MethodLength -- Both small files form one reusable fixture project. + # rubocop:disable-next Metrics/MethodLength -- Both small files form one reusable fixture project. def build_project(root) write_source(root, 'lib/models/order.rb', <<~RUBY) class Sales::Order @@ -26,9 +26,8 @@ def call end RUBY end - # rubocop:enable Metrics/MethodLength - # rubocop:disable Metrics/MethodLength -- Four files create both distance zones. + # rubocop:disable-next Metrics/MethodLength -- Four files create both distance zones. def build_distance_project(root) write_source(root, 'lib/stable.rb', <<~RUBY) class Stable @@ -50,7 +49,6 @@ def perform end RUBY end - # rubocop:enable Metrics/MethodLength it 'builds immutable selectors without touching the filesystem' do allow(ArchUnit::MetricExtraction).to receive(:extract_project_info).and_call_original diff --git a/spec/packaging/gemspec_spec.rb b/spec/packaging/gemspec_spec.rb index 92245a4..e007522 100644 --- a/spec/packaging/gemspec_spec.rb +++ b/spec/packaging/gemspec_spec.rb @@ -20,6 +20,12 @@ expect(specification.files).to include(*expected_sources) end + it 'packages the user guide, API guide, and family logo' do + expect(specification.files).to include( + 'README.md', 'API.md', 'assets/logo-rounded.png' + ) + end + it 'declares every library needed by a clean installed graph renderer' do dependencies = specification.runtime_dependencies.to_h do |dependency| [dependency.name, dependency.requirement.to_s]