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/.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..46d074a --- /dev/null +++ b/API.md @@ -0,0 +1,210 @@ +# 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'], + 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. + +## 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 957d1ee..7d642fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- 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..b9918fa 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source 'https://rubygems.org' gemspec +gem 'irb', '~> 1.15' gem 'kramdown', '~> 2.5.2' gem 'minitest', '~> 5.25' gem 'rake', '~> 13.2' diff --git a/Gemfile.lock b/Gemfile.lock index 5df65cd..56cf077 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) 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) + 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.12.0) + reline (0.7.0) + io-console (~> 0.5) rexml (3.4.4) rspec (3.13.2) rspec-core (~> 3.13.0) @@ -63,6 +85,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + tsort (0.2.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -74,6 +97,7 @@ PLATFORMS DEPENDENCIES archunit! + irb (~> 1.15) kramdown (~> 2.5.2) minitest (~> 5.25) rake (~> 13.2) @@ -88,18 +112,27 @@ 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 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 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + 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 @@ -112,6 +145,7 @@ CHECKSUMS simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f yard (0.9.45) sha256=52e211493f7cb8a3ebf7e104a25a1e73937a3103092545d34cb88fafebb3dc51 diff --git a/README.md b/README.md index a7a220e..dc7a38f 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,28 +18,30 @@ 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: -```ruby +~~~ruby ArchUnit.project_files .in_folder('app/api/**') .should_not.depend_on_files .in_folder('app/database/**') -``` +~~~ It is a working executable prototype with file, layer, slice, graph-reporting, and metric APIs. It is tested on Ruby 3.3, 3.4, and 4.0 on Linux and Ruby 4.0 on Windows. Version 0.0.1 is available as [`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 @@ -42,18 +49,18 @@ site is rebuilt in CI and deployed from `main`, so the published reference follo ArchUnitRuby requires Ruby 3.3 or newer. Add it to your test dependencies: -```ruby +~~~ruby # Gemfile group :test do gem 'archunit', '~> 0.0.1' end -``` +~~~ Then install it: -```bash +~~~bash bundle install -``` +~~~ Or install it directly with `gem install archunit`. @@ -70,7 +77,7 @@ for you. Create `spec/architecture_spec.rb`: -```ruby +~~~ruby require 'archunit' RSpec.describe 'architecture' do @@ -81,24 +88,24 @@ RSpec.describe 'architecture' do expect(rule).to pass end end -``` +~~~ Run it like any other specification: -```bash +~~~bash bundle exec rspec spec/architecture_spec.rb -``` +~~~ ### CI Integration Architecture specifications run with the rest of the test suite, so no dedicated CI integration is required: -```yaml +~~~yaml # GitHub Actions - name: Run architecture tests run: bundle exec rspec spec/architecture_spec.rb -``` +~~~ ## 🚐 Setup @@ -107,10 +114,10 @@ is required: The project locator is optional. With no argument, ArchUnitRuby searches from the current directory for a `Gemfile` or gemspec. Pass a directory or either marker file when analyzing another project: -```ruby +~~~ruby ArchUnit.project_files('/workspace/my_app') ArchUnit.project_files('/workspace/my_app/Gemfile') -``` +~~~ ### Fluent Grammar @@ -128,12 +135,12 @@ Every rule is built left to right from the same small grammar: Building a rule is lazy and does not scan the filesystem. `check`, `measure`, snapshot/report terminals, and export terminals perform the work. Builders are immutable, so a scope can be reused: -```ruby +~~~ruby services = ArchUnit.project_files.in_folder('app/services/**') cycle_rule = services.should.have_no_cycles database_rule = services.should_not.depend_on_files.in_folder('app/database/**') -``` +~~~ String patterns are anchored globs. `*` stays inside one path segment, `**` crosses directories, and `?` matches one non-separator character. Most selectors also accept regular expressions; @@ -143,9 +150,9 @@ Paths are project-relative and normalized to `/` separators. A scope matching zero files returns `EmptyTestViolation`; it does not silently pass. Opt out only when an empty result is genuinely valid: -```ruby +~~~ruby rule.check(ArchUnit::CheckOptions.new(allow_empty_tests: true)) -``` +~~~ ## 🐣 Features @@ -154,7 +161,7 @@ rule.check(ArchUnit::CheckOptions.new(allow_empty_tests: true)) File rules cover cycles, naming, location, internal dependencies, external modules, and custom source predicates: -```ruby +~~~ruby rules = [ ArchUnit.project_files.in_path('lib/**/*.rb').should.have_no_cycles, ArchUnit.project_files.in_folder('app/services/**') @@ -164,24 +171,24 @@ rules = [ ] rules.each { |rule| ArchUnit.assert_passes(rule) } -``` +~~~ A custom predicate receives an immutable `FileInfo` with `path`, `name`, `extension`, `directory`, complete `content`, and non-blank `lines_of_code`: -```ruby +~~~ruby rule = ArchUnit.project_files.in_folder('app/services/**') .should.adhere_to( ->(file) { file.lines_of_code < 300 }, 'services must stay below 300 non-blank lines' ) -``` +~~~ ### Layer Dependencies Named layers express an allowlist or blocklist over groups of files: -```ruby +~~~ruby rule = ArchUnit.project_layers .layer('api').defined_by('app/api/**/*.rb') .layer('services').defined_by('app/services/**/*.rb') @@ -191,7 +198,7 @@ rule = ArchUnit.project_layers .where_layer('database').may_only_depend_on_layers expect(rule).to pass -``` +~~~ Dependencies within one layer are always allowed. Edges with an unassigned endpoint are ignored. Calling `may_only_depend_on_layers` without targets seals a layer; `may_not_depend_on_layers` @@ -201,24 +208,24 @@ requires at least one forbidden target. Slices group files by one captured path segment and preserve every concrete dependency as evidence: -```ruby +~~~ruby slices = ArchUnit.project_slices.defined_by('lib/my_app/(**)/') rule = slices.should_not.contain_dependency('api', 'database') expect(rule).to pass -``` +~~~ `(**)` is the slice capture. `defined_by_regex` uses the first regular-expression capture instead. A checked-in PlantUML component diagram can also be the architecture contract: -```ruby +~~~ruby rule = slices.should .ignoring_external_slices .adhere_to_diagram_in_file('docs/architecture.puml') expect(rule).to pass -``` +~~~ The supported subset recognizes components, directed dependencies, comments, and `@startuml` / `@enduml`. Use `to_plantuml` or `export_as_plantuml(path)` to generate a diagram from the real graph. @@ -228,7 +235,7 @@ The supported subset recognizes components, directed dependencies, comments, and Graph reporting builds one immutable snapshot and renders it consistently as DOT, Mermaid, D2, CSV, JSON, or self-contained HTML: -```ruby +~~~ruby report = ArchUnit.project_graph .include_external_dependencies .focus_on('app/services/**', 2) @@ -237,7 +244,7 @@ report = ArchUnit.project_graph puts report.summary.node_count report.export_as_html('reports/services.html') -``` +~~~ Queries include `focus_on`, `reachable_from`, and `dependents_of`. Collapse by folder depth or a regular-expression replacement. Every format has an in-memory `to_` and an @@ -247,7 +254,7 @@ regular-expression replacement. Every format has an in-memory `to_` and Metric scopes select files and Ruby classes before measurement or assertion: -```ruby +~~~ruby services = ArchUnit.metrics .in_path('app/services/**/*.rb') .for_classes_matching('*Service') @@ -257,7 +264,7 @@ cohesion_rule = services.lcom.lcom4.should_be(1) distance_rule = services.distance.instability.should_be_below(0.8) [size_rule, cohesion_rule, distance_rule].each { |rule| ArchUnit.assert_passes(rule) } -``` +~~~ Count metrics cover class methods and fields plus file lines, statements, imports, classes, and top-level functions. Cohesion includes LCOM96a, LCOM96b, LCOM1-5, and LCOM*. Dependency-derived @@ -267,9 +274,9 @@ distance. Zone guards detect the conventional zones of pain and uselessness. Use `measure` for immutable numeric results, `custom_metric` for a calculation over `ClassInfo`, and `export_as_html` for an offline metrics report: -```ruby +~~~ruby services.count.export_as_html('reports/service-counts') -``` +~~~ The threshold vocabulary is intentionally limited to `should_be_below`, `should_be_above`, `should_be`, `should_be_below_or_equal`, `should_be_above_or_equal`, and `should_satisfy`. @@ -279,22 +286,22 @@ The threshold vocabulary is intentionally limited to `should_be_below`, `should_ Every selector accepts `except:` in the same call. A plain pattern or array uses the parent selector's context, including filenames for path and folder selectors: -```ruby +~~~ruby scope = ArchUnit.project_files.in_path( 'app/**/*.rb', except: ['app/generated/**', 'schema.rb'] ) -``` +~~~ Use explicit targets when needed. Supported keys are `in_path`, `in_folder`, `with_name`, and `for_classes_matching`: -```ruby +~~~ruby scope = ArchUnit.metrics.in_path( 'app/**/*.rb', except: { in_folder: 'app/generated', with_name: '*_spec.rb' } ) -``` +~~~ ## 🐹 Use Cases @@ -303,18 +310,18 @@ scope = ArchUnit.metrics.in_path( `check` returns an array of structured violations. Architecture disagreement is data, not an exception: -```ruby +~~~ruby violations = rule.check violations.each { |violation| puts violation.class } -``` +~~~ Translate that result into a test failure at the boundary that suits your suite: -```ruby +~~~ruby expect(rule).to pass # RSpec assert_passes(rule) # Minitest test case ArchUnit.assert_passes(rule) # Framework-neutral -``` +~~~ `ArchUnit.format_violations` and `ResultFactory` provide stable human-readable output. All violations retain the concrete dependency, file, layer, slice, or metric evidence that caused them. @@ -323,7 +330,7 @@ violations retain the concrete dependency, file, layer, slice, or metric evidenc Logging is off by default and belongs to one check; there is no process-global configuration: -```ruby +~~~ruby logging = ArchUnit::LoggingOptions.new( level: :debug, output_directory: 'tmp/archunit-logs', @@ -331,7 +338,7 @@ logging = ArchUnit::LoggingOptions.new( ) violations = rule.check(ArchUnit::CheckOptions.new(logging: logging)) -``` +~~~ Levels are `debug`, `info`, `warn`, and `error`. The fixed events cover check start/end, progress, violations, and metric evidence. `io:` defaults to `$stderr`, accepts any writable stream, and may be @@ -354,12 +361,12 @@ Project dependencies use normalized, project-relative paths. Standard-library an retain the module name written in source. Inline or immediately preceding ignore directives can suppress known compatibility imports: -```ruby +~~~ruby require 'legacy/client' # archunit: ignore legacy/client # archunit: ignore experimental/plugin 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. @@ -370,14 +377,14 @@ never evaluated. Add non-standard source roots explicitly through the per-check are relative to the project root, must remain inside it, and use the order given after the normal top-level `lib` and project-root search locations: -```ruby +~~~ruby options = ArchUnit::CheckOptions.new( load_paths: ['components/billing/source', 'plugins/search/lib'] ) 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 @@ -396,14 +403,16 @@ the complete library graph must remain cycle-free. ## 🦊 Contributing -```bash +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 bundle install bundle exec rake bundle exec rake docs gem build archunit.gemspec --strict -``` +~~~ `bundle exec rake` runs the randomized RSpec suite and RuboCop. CI additionally enforces 98% line and 90% branch coverage, runs the dogfooding rules explicitly, builds the documentation, loads the @@ -415,9 +424,9 @@ Cold and warm extraction can be profiled independently with a generated multi-ge benchmark reports stage timings, resolution-cache effectiveness, Ruby heap growth, and peak RSS on platforms that expose it: -```bash +~~~bash bundle exec ruby benchmark/extraction.rb -``` +~~~ See the [benchmark guide](https://github.com/LukasNiessen/ArchUnitRuby/blob/main/benchmark/README.md) for corpus controls, JSON output, and CI limits. @@ -443,6 +452,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/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..37296e6 100644 --- a/lib/archunit.rb +++ b/lib/archunit.rb @@ -107,6 +107,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/fluentapi/check_options.rb b/lib/archunit/common/fluentapi/check_options.rb index c0a91eb..5112487 100644 --- a/lib/archunit/common/fluentapi/check_options.rb +++ b/lib/archunit/common/fluentapi/check_options.rb @@ -7,6 +7,9 @@ module Common module FluentApi # Immutable options shared by every terminal rule check. CheckOptions = Data.define(:allow_empty_tests, :logging, :clear_cache, :load_paths) 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,6 +17,10 @@ def self.resolve(value) raise ArgumentError, 'options must be a CheckOptions value or nil' end + # @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 def initialize(allow_empty_tests: false, logging: nil, clear_cache: false, load_paths: []) validate_boolean(allow_empty_tests, :allow_empty_tests) validate_logging(logging) @@ -22,10 +29,12 @@ def initialize(allow_empty_tests: false, logging: nil, clear_cache: false, load_ 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 diff --git a/lib/archunit/common/fluentapi/checkable.rb b/lib/archunit/common/fluentapi/checkable.rb index b359d56..f04d7bf 100644 --- a/lib/archunit/common/fluentapi/checkable.rb +++ b/lib/archunit/common/fluentapi/checkable.rb @@ -10,6 +10,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/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/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 8e734ef..e02e7be 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/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..0a63864 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,6 +39,8 @@ def initialize(scope:, metric:) freeze end + # Evaluates this metric for every selected subject. + # @return [Array] def measure scope.__send__(:subjects_for, metric.subject_type).map do |subject| MetricMeasurement.new( @@ -45,6 +57,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 cdf985f..2564c11 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/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/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/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]