archunit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +7 -0
- data/README.md +375 -0
- data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
- data/lib/archunit/common/assertion/violation.rb +14 -0
- data/lib/archunit/common/extraction/edge.rb +46 -0
- data/lib/archunit/common/extraction/graph.rb +73 -0
- data/lib/archunit/common/extraction/import_kind.rb +21 -0
- data/lib/archunit/common/filter.rb +46 -0
- data/lib/archunit/common/fluentapi/check_options.rb +48 -0
- data/lib/archunit/common/fluentapi/checkable.rb +70 -0
- data/lib/archunit/common/logging/check_logger.rb +124 -0
- data/lib/archunit/common/logging/logging_options.rb +57 -0
- data/lib/archunit/common/pattern.rb +99 -0
- data/lib/archunit/common/pattern_matching.rb +76 -0
- data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
- data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
- data/lib/archunit/common/projection/edge_projections.rb +50 -0
- data/lib/archunit/common/projection/mapped_edge.rb +26 -0
- data/lib/archunit/common/projection/project_cycles.rb +92 -0
- data/lib/archunit/common/projection/project_edges.rb +69 -0
- data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
- data/lib/archunit/common/projection/projected_edge.rb +35 -0
- data/lib/archunit/common/projection/projected_node.rb +36 -0
- data/lib/archunit/common/regex_factory.rb +96 -0
- data/lib/archunit/error/technical_error.rb +6 -0
- data/lib/archunit/error/user_error.rb +6 -0
- data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
- data/lib/archunit/extraction/extract_dependencies.rb +61 -0
- data/lib/archunit/extraction/extract_graph.rb +89 -0
- data/lib/archunit/extraction/extract_imports.rb +197 -0
- data/lib/archunit/extraction/locate_project.rb +80 -0
- data/lib/archunit/extraction/resolve_import.rb +76 -0
- data/lib/archunit/extraction/resolved_import.rb +46 -0
- data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
- data/lib/archunit/files/assertion/cycle_free.rb +58 -0
- data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
- data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
- data/lib/archunit/files/assertion/matching_files.rb +87 -0
- data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
- data/lib/archunit/files/extraction/file_info.rb +46 -0
- data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
- data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
- data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
- data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
- data/lib/archunit/files/fluentapi/files.rb +31 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
- data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
- data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
- data/lib/archunit/graph/fluentapi/graph.rb +29 -0
- data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
- data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
- data/lib/archunit/graph/projection/collapse_node.rb +33 -0
- data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
- data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
- data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
- data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
- data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
- data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
- data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
- data/lib/archunit/graph/projection/node_selection.rb +111 -0
- data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
- data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
- data/lib/archunit/graph/rendering/escaping.rb +31 -0
- data/lib/archunit/graph/rendering/export_report.rb +29 -0
- data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
- data/lib/archunit/graph/rendering/html_document.rb +46 -0
- data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
- data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
- data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
- data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
- data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
- data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
- data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
- data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
- data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
- data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
- data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
- data/lib/archunit/layers/fluentapi/layers.rb +29 -0
- data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
- data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
- data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
- data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
- data/lib/archunit/metrics/calculation/count.rb +45 -0
- data/lib/archunit/metrics/calculation/distance.rb +84 -0
- data/lib/archunit/metrics/calculation/lcom.rb +103 -0
- data/lib/archunit/metrics/calculation/metric.rb +52 -0
- data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
- data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
- data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
- data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
- data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
- data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
- data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
- data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
- data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
- data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
- data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
- data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
- data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
- data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
- data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
- data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
- data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
- data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
- data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
- data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
- data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
- data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
- data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
- data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
- data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
- data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
- data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
- data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
- data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
- data/lib/archunit/slices/fluentapi/slices.rb +29 -0
- data/lib/archunit/slices/projection/slice_projection.rb +76 -0
- data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
- data/lib/archunit/slices/uml/export_diagram.rb +70 -0
- data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
- data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
- data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
- data/lib/archunit/testing/assert_passes.rb +24 -0
- data/lib/archunit/testing/assertion_failure.rb +22 -0
- data/lib/archunit/testing/color_utils.rb +66 -0
- data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
- data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
- data/lib/archunit/testing/minitest_adapter.rb +33 -0
- data/lib/archunit/testing/result_factory.rb +93 -0
- data/lib/archunit/testing/rspec_adapter.rb +74 -0
- data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
- data/lib/archunit/testing/test_result.rb +26 -0
- data/lib/archunit/testing/test_violation.rb +23 -0
- data/lib/archunit/testing/violation_factory.rb +138 -0
- data/lib/archunit/testing.rb +39 -0
- data/lib/archunit/version.rb +5 -0
- data/lib/archunit.rb +110 -0
- metadata +251 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f46c756e84f01c2d6a5b1d4e39865dbb86181dd228cbdb1ec0bbf21110e77f37
|
|
4
|
+
data.tar.gz: 37d35e46c78fc1afd46d6a86a998257c2157c36d09d6a2f73ff520a3437f6209
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 95d51bad089bd4f9ad8b1f9f4a61e0c768221d54bbd89ea9e074ca31b39b79c107208fdf53530f0e59f971d82d3b698cb1a34d714bd5a787147ae8f5f7130218
|
|
7
|
+
data.tar.gz: fcd6d488f34413e94a24fc4780a1feb6f15b511d5138af9510b3550299163a7821f2f3fc11049039a6d9465f02b3ea7966e5418758b958c7f38a417d2e377446
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1 - 2026-08-11
|
|
4
|
+
|
|
5
|
+
Initial executable prototype.
|
|
6
|
+
|
|
7
|
+
- Analyze Ruby dependencies expressed with `require`, `require_relative`, `autoload`, and `load`.
|
|
8
|
+
- Enforce file, layer, slice, PlantUML, and custom-predicate architecture rules.
|
|
9
|
+
- Measure count, cohesion, coupling, instability, abstractness, and main-sequence metrics.
|
|
10
|
+
- Export dependency graphs and metric reports in machine-readable and visual formats.
|
|
11
|
+
- Integrate with RSpec, Minitest, or framework-neutral assertions.
|
|
12
|
+
- Provide per-check logging, selector exclusions, generated API documentation, and Ruby 3.3+
|
|
13
|
+
compatibility.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Lukas Niessen <lks.niessen@gmail.com> https://lukasniessen.com
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# ArchUnitRuby
|
|
2
|
+
|
|
3
|
+
Architecture testing for Ruby. Part of **ArchUnitEverything**: one recognizable testing library
|
|
4
|
+
for each programming language.
|
|
5
|
+
|
|
6
|
+
[](https://github.com/LukasNiessen/ArchUnitRuby/actions/workflows/ci.yml)
|
|
7
|
+
[](https://lukasniessen.github.io/ArchUnitRuby/)
|
|
8
|
+
[](https://rubygems.org/gems/archunit)
|
|
9
|
+
[](https://rubygems.org/gems/archunit)
|
|
10
|
+
[](https://www.ruby-lang.org/)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
[](https://github.com/LukasNiessen/ArchUnitRuby)
|
|
13
|
+
|
|
14
|
+
ArchUnitRuby turns a Ruby codebase into a dependency graph and lets you test that graph with rules
|
|
15
|
+
that read like English:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
ArchUnit.project_files
|
|
19
|
+
.in_folder('app/api/**')
|
|
20
|
+
.should_not.depend_on_files
|
|
21
|
+
.in_folder('app/database/**')
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It is a working executable prototype with file, layer, slice, graph-reporting, and metric APIs. It
|
|
25
|
+
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
|
|
26
|
+
[`archunit`](https://rubygems.org/gems/archunit) on RubyGems.
|
|
27
|
+
|
|
28
|
+
Siblings: [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS) and
|
|
29
|
+
[ArchUnitPython](https://github.com/LukasNiessen/ArchUnitPython).
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
The [documentation site](https://lukasniessen.github.io/ArchUnitRuby/) combines this guide with a
|
|
34
|
+
searchable, source-generated API reference for every public module, class, and method. The same
|
|
35
|
+
site is rebuilt in CI and deployed from `main`, so the published reference follows the repository.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
ArchUnitRuby requires Ruby 3.3 or newer. Add it to your test dependencies:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Gemfile
|
|
43
|
+
group :test do
|
|
44
|
+
gem 'archunit', '~> 0.0.1'
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then install it:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bundle install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or install it directly with `gem install archunit`.
|
|
55
|
+
|
|
56
|
+
RSpec and Minitest integrations are optional; ArchUnitRuby does not install either test framework
|
|
57
|
+
for you.
|
|
58
|
+
|
|
59
|
+
## Your first rule
|
|
60
|
+
|
|
61
|
+
Create `spec/architecture_spec.rb`:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
require 'archunit'
|
|
65
|
+
|
|
66
|
+
RSpec.describe 'architecture' do
|
|
67
|
+
it 'keeps the API away from the database' do
|
|
68
|
+
rule = ArchUnit.project_files.in_folder('app/api/**')
|
|
69
|
+
.should_not.depend_on_files.in_folder('app/database/**')
|
|
70
|
+
|
|
71
|
+
expect(rule).to pass
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run it like any other specification:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bundle exec rspec spec/architecture_spec.rb
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The project locator is optional. With no argument, ArchUnitRuby searches from the current directory
|
|
83
|
+
for a `Gemfile` or gemspec. Pass a directory or either marker file when analyzing another project:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
ArchUnit.project_files('/workspace/my_app')
|
|
87
|
+
ArchUnit.project_files('/workspace/my_app/Gemfile')
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## The fluent grammar
|
|
91
|
+
|
|
92
|
+
Every rule is built left to right from the same small grammar:
|
|
93
|
+
|
|
94
|
+
| Stage | Purpose | Examples |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| Entry | Choose the architectural vocabulary | `project_files`, `project_layers`, `project_slices`, `project_graph`, `metrics` |
|
|
97
|
+
| Scope | Select subjects; repeated scopes use AND | `in_folder`, `in_path`, `with_name`, `for_classes_matching` |
|
|
98
|
+
| Mood | Choose the expected direction | `should`, `should_not` |
|
|
99
|
+
| Predicate | State the policy | `have_no_cycles`, `depend_on_files`, `adhere_to_diagram` |
|
|
100
|
+
| Object | Select the target of a relational rule | `in_folder`, `matching`, a layer or slice name |
|
|
101
|
+
| Terminal | Execute or render | `check`, `to_json`, `export_as_html`, `measure` |
|
|
102
|
+
|
|
103
|
+
Building a rule is lazy and does not scan the filesystem. `check`, `measure`, snapshot/report
|
|
104
|
+
terminals, and export terminals perform the work. Builders are immutable, so a scope can be reused:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
services = ArchUnit.project_files.in_folder('app/services/**')
|
|
108
|
+
|
|
109
|
+
cycle_rule = services.should.have_no_cycles
|
|
110
|
+
database_rule = services.should_not.depend_on_files.in_folder('app/database/**')
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
String patterns are anchored globs. `*` stays inside one path segment, `**` crosses directories,
|
|
114
|
+
and `?` matches one non-separator character. Most selectors also accept regular expressions;
|
|
115
|
+
`defined_by_regex` is the explicit regex form for slices, while `in_file` takes one exact path.
|
|
116
|
+
Paths are project-relative and normalized to `/` separators.
|
|
117
|
+
|
|
118
|
+
A scope matching zero files returns `EmptyTestViolation`; it does not silently pass. Opt out only
|
|
119
|
+
when an empty result is genuinely valid:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
rule.check(ArchUnit::CheckOptions.new(allow_empty_tests: true))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Files
|
|
126
|
+
|
|
127
|
+
File rules cover cycles, naming, location, internal dependencies, external modules, and custom
|
|
128
|
+
source predicates:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
rules = [
|
|
132
|
+
ArchUnit.project_files.in_path('lib/**/*.rb').should.have_no_cycles,
|
|
133
|
+
ArchUnit.project_files.in_folder('app/services/**')
|
|
134
|
+
.should.have_name('*_service.rb'),
|
|
135
|
+
ArchUnit.project_files.in_folder('app/domain/**')
|
|
136
|
+
.should_not.depend_on_external_modules.matching('faraday')
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
rules.each { |rule| ArchUnit.assert_passes(rule) }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A custom predicate receives an immutable `FileInfo` with `path`, `name`, `extension`, `directory`,
|
|
143
|
+
complete `content`, and non-blank `lines_of_code`:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
rule = ArchUnit.project_files.in_folder('app/services/**')
|
|
147
|
+
.should.adhere_to(
|
|
148
|
+
->(file) { file.lines_of_code < 300 },
|
|
149
|
+
'services must stay below 300 non-blank lines'
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Layers
|
|
154
|
+
|
|
155
|
+
Named layers express an allowlist or blocklist over groups of files:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
rule = ArchUnit.project_layers
|
|
159
|
+
.layer('api').defined_by('app/api/**/*.rb')
|
|
160
|
+
.layer('services').defined_by('app/services/**/*.rb')
|
|
161
|
+
.layer('database').defined_by('app/database/**/*.rb')
|
|
162
|
+
.where_layer('api').may_only_depend_on_layers('services')
|
|
163
|
+
.where_layer('services').may_only_depend_on_layers('database')
|
|
164
|
+
.where_layer('database').may_only_depend_on_layers
|
|
165
|
+
|
|
166
|
+
expect(rule).to pass
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Dependencies within one layer are always allowed. Edges with an unassigned endpoint are ignored.
|
|
170
|
+
Calling `may_only_depend_on_layers` without targets seals a layer; `may_not_depend_on_layers`
|
|
171
|
+
requires at least one forbidden target.
|
|
172
|
+
|
|
173
|
+
## Slices and PlantUML
|
|
174
|
+
|
|
175
|
+
Slices group files by one captured path segment and preserve every concrete dependency as evidence:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
slices = ArchUnit.project_slices.defined_by('lib/my_app/(**)/')
|
|
179
|
+
rule = slices.should_not.contain_dependency('api', 'database')
|
|
180
|
+
|
|
181
|
+
expect(rule).to pass
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`(**)` is the slice capture. `defined_by_regex` uses the first regular-expression capture instead.
|
|
185
|
+
|
|
186
|
+
A checked-in PlantUML component diagram can also be the architecture contract:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
rule = slices.should
|
|
190
|
+
.ignoring_external_slices
|
|
191
|
+
.adhere_to_diagram_in_file('docs/architecture.puml')
|
|
192
|
+
|
|
193
|
+
expect(rule).to pass
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The supported subset recognizes components, directed dependencies, comments, and `@startuml` /
|
|
197
|
+
`@enduml`. Use `to_plantuml` or `export_as_plantuml(path)` to generate a diagram from the real graph.
|
|
198
|
+
|
|
199
|
+
## Dependency graph reports
|
|
200
|
+
|
|
201
|
+
Graph reporting builds one immutable snapshot and renders it consistently as DOT, Mermaid, D2, CSV,
|
|
202
|
+
JSON, or self-contained HTML:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
report = ArchUnit.project_graph
|
|
206
|
+
.include_external_dependencies
|
|
207
|
+
.focus_on('app/services/**', 2)
|
|
208
|
+
.collapse_to_folder_depth(2)
|
|
209
|
+
.titled('Service dependencies')
|
|
210
|
+
|
|
211
|
+
puts report.summary.node_count
|
|
212
|
+
report.export_as_html('reports/services.html')
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Queries include `focus_on`, `reachable_from`, and `dependents_of`. Collapse by folder depth or a
|
|
216
|
+
regular-expression replacement. Every format has an in-memory `to_<format>` and an
|
|
217
|
+
`export_as_<format>(path)` terminal.
|
|
218
|
+
|
|
219
|
+
## Metrics
|
|
220
|
+
|
|
221
|
+
Metric scopes select files and Ruby classes before measurement or assertion:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
services = ArchUnit.metrics
|
|
225
|
+
.in_path('app/services/**/*.rb')
|
|
226
|
+
.for_classes_matching('*Service')
|
|
227
|
+
|
|
228
|
+
size_rule = services.count.method_count.should_be_below_or_equal(20)
|
|
229
|
+
cohesion_rule = services.lcom.lcom4.should_be(1)
|
|
230
|
+
distance_rule = services.distance.instability.should_be_below(0.8)
|
|
231
|
+
|
|
232
|
+
[size_rule, cohesion_rule, distance_rule].each { |rule| ArchUnit.assert_passes(rule) }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Count metrics cover class methods and fields plus file lines, statements, imports, classes, and
|
|
236
|
+
top-level functions. Cohesion includes LCOM96a, LCOM96b, LCOM1-5, and LCOM*. Dependency-derived
|
|
237
|
+
metrics include abstractness, instability, main-sequence distance, coupling factor, and normalized
|
|
238
|
+
distance. Zone guards detect the conventional zones of pain and uselessness.
|
|
239
|
+
|
|
240
|
+
Use `measure` for immutable numeric results, `custom_metric` for a calculation over `ClassInfo`, and
|
|
241
|
+
`export_as_html` for an offline metrics report:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
services.count.export_as_html('reports/service-counts')
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The threshold vocabulary is intentionally limited to `should_be_below`, `should_be_above`,
|
|
248
|
+
`should_be`, `should_be_below_or_equal`, `should_be_above_or_equal`, and `should_satisfy`.
|
|
249
|
+
|
|
250
|
+
## Pattern exclusions
|
|
251
|
+
|
|
252
|
+
Every selector accepts `except:` in the same call. A plain pattern or array uses the parent
|
|
253
|
+
selector's context, including filenames for path and folder selectors:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
scope = ArchUnit.project_files.in_path(
|
|
257
|
+
'app/**/*.rb',
|
|
258
|
+
except: ['app/generated/**', 'schema.rb']
|
|
259
|
+
)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Use explicit targets when needed. Supported keys are `in_path`, `in_folder`, `with_name`, and
|
|
263
|
+
`for_classes_matching`:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
scope = ArchUnit.metrics.in_path(
|
|
267
|
+
'app/**/*.rb',
|
|
268
|
+
except: { in_folder: 'app/generated', with_name: '*_spec.rb' }
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Results and test frameworks
|
|
273
|
+
|
|
274
|
+
`check` returns an array of structured violations. Architecture disagreement is data, not an
|
|
275
|
+
exception:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
violations = rule.check
|
|
279
|
+
violations.each { |violation| puts violation.class }
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Translate that result into a test failure at the boundary that suits your suite:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
expect(rule).to pass # RSpec
|
|
286
|
+
assert_passes(rule) # Minitest test case
|
|
287
|
+
ArchUnit.assert_passes(rule) # Framework-neutral
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`ArchUnit.format_violations` and `ResultFactory` provide stable human-readable output. All
|
|
291
|
+
violations retain the concrete dependency, file, layer, slice, or metric evidence that caused them.
|
|
292
|
+
|
|
293
|
+
## Per-check logging
|
|
294
|
+
|
|
295
|
+
Logging is off by default and belongs to one check; there is no process-global configuration:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
logging = ArchUnit::LoggingOptions.new(
|
|
299
|
+
level: :debug,
|
|
300
|
+
output_directory: 'tmp/archunit-logs',
|
|
301
|
+
append: false
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
violations = rule.check(ArchUnit::CheckOptions.new(logging: logging))
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Levels are `debug`, `info`, `warn`, and `error`. The fixed events cover check start/end, progress,
|
|
308
|
+
violations, and metric evidence. `io:` defaults to `$stderr`, accepts any writable stream, and may be
|
|
309
|
+
`nil`. File output creates missing directories and writes timestamped `archunit-*.log` files.
|
|
310
|
+
|
|
311
|
+
## What ArchUnitRuby extracts
|
|
312
|
+
|
|
313
|
+
ArchUnitRuby uses Prism and statically recognizes:
|
|
314
|
+
|
|
315
|
+
| Ruby source form | Import kind |
|
|
316
|
+
| --- | --- |
|
|
317
|
+
| `require 'json'` | `:require` |
|
|
318
|
+
| `require_relative '../models/order'` | `:require_relative` |
|
|
319
|
+
| `autoload :Order, 'models/order'` | `:autoload` |
|
|
320
|
+
| `load 'config/setup.rb'` | `:load` |
|
|
321
|
+
|
|
322
|
+
Project dependencies use normalized, project-relative paths. Standard-library and gem dependencies
|
|
323
|
+
retain the module name written in source. Inline or immediately preceding ignore directives can
|
|
324
|
+
suppress known compatibility imports:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
require 'legacy/client' # archunit: ignore legacy/client
|
|
328
|
+
|
|
329
|
+
# archunit: ignore experimental/plugin
|
|
330
|
+
require 'experimental/plugin'
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Dynamic imports such as `require dependency_name` or `require "plugins/#{name}"` are omitted rather
|
|
334
|
+
than guessed because resolving them would require executing application code.
|
|
335
|
+
|
|
336
|
+
## Executable examples
|
|
337
|
+
|
|
338
|
+
The [ArchUnitRuby RAG test repository](https://github.com/TristanKruse/ArchUnitRuby-TestRepo-RAG)
|
|
339
|
+
is a small layered retrieval-augmented-generation application with two deliberate architecture
|
|
340
|
+
violations. Its test suite exercises the public APIs above against real Ruby source on Linux and
|
|
341
|
+
Windows.
|
|
342
|
+
|
|
343
|
+
ArchUnitRuby also dogfoods itself in `spec/architecture_spec.rb`: `common` is isolated, domain
|
|
344
|
+
modules cannot depend on one another, implementation files cannot depend on the public surface, and
|
|
345
|
+
the complete library graph must remain cycle-free.
|
|
346
|
+
|
|
347
|
+
## Development
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
git clone https://github.com/LukasNiessen/ArchUnitRuby.git
|
|
351
|
+
cd ArchUnitRuby
|
|
352
|
+
bundle install
|
|
353
|
+
bundle exec rake
|
|
354
|
+
bundle exec rake docs
|
|
355
|
+
gem build archunit.gemspec --strict
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`bundle exec rake` runs the randomized RSpec suite and RuboCop. CI additionally enforces 98% line
|
|
359
|
+
and 90% branch coverage, runs the dogfooding rules explicitly, builds the documentation, loads the
|
|
360
|
+
public API with Ruby warnings, tests optional RSpec/Minitest integrations, builds and installs the
|
|
361
|
+
gem artifact, runs the external RAG fixture, and checks Ruby 3.3, 3.4, and 4.0 across Ubuntu and
|
|
362
|
+
Windows.
|
|
363
|
+
|
|
364
|
+
The implementation conventions and intended dependency directions live in [`AGENTS.md`](AGENTS.md).
|
|
365
|
+
|
|
366
|
+
## Current limitations
|
|
367
|
+
|
|
368
|
+
- Ruby constants are not modeled as a separate graph. Files are the primary dependency vocabulary.
|
|
369
|
+
- Dynamic `require`, `autoload`, and `load` arguments cannot be resolved statically.
|
|
370
|
+
- PlantUML support is a deliberately small component-diagram subset, not a complete UML parser.
|
|
371
|
+
- The API is still pre-release and may change before the first stable gem version.
|
|
372
|
+
|
|
373
|
+
## License
|
|
374
|
+
|
|
375
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../filter'
|
|
4
|
+
require_relative 'violation'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Common
|
|
8
|
+
module Assertion
|
|
9
|
+
# Data describing a rule whose selectors matched no candidates.
|
|
10
|
+
class EmptyTestViolation < Violation
|
|
11
|
+
attr_reader :filters, :is_negated
|
|
12
|
+
|
|
13
|
+
def initialize(filters:, is_negated: false)
|
|
14
|
+
@filters = immutable_filters(filters)
|
|
15
|
+
@is_negated = boolean(is_negated, :is_negated)
|
|
16
|
+
super()
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def negated?
|
|
20
|
+
is_negated
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
other.is_a?(self.class) && filters == other.filters && is_negated == other.is_negated
|
|
25
|
+
end
|
|
26
|
+
alias eql? ==
|
|
27
|
+
|
|
28
|
+
def hash
|
|
29
|
+
[self.class, filters, is_negated].hash
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def immutable_filters(values)
|
|
35
|
+
unless values.is_a?(Array) && values.all?(Filter)
|
|
36
|
+
raise ArgumentError, 'filters must be an Array of Filter values'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
values.dup.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def boolean(value, attribute)
|
|
43
|
+
return value if [true, false].include?(value)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "#{attribute} must be true or false"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'import_kind'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
module Extraction
|
|
8
|
+
Edge = Data.define(:source, :target, :external, :import_kinds) do
|
|
9
|
+
def initialize(source:, target:, external:, import_kinds: [])
|
|
10
|
+
source = immutable_identifier(source, :source)
|
|
11
|
+
target = immutable_identifier(target, :target)
|
|
12
|
+
validate_external(external)
|
|
13
|
+
import_kinds = immutable_import_kinds(import_kinds)
|
|
14
|
+
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def immutable_identifier(value, attribute)
|
|
21
|
+
unless value.is_a?(String) && !value.empty?
|
|
22
|
+
raise ArgumentError, "#{attribute} must be a non-empty String"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
value.tr('\\', '/').freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_external(value)
|
|
29
|
+
return if [true, false].include?(value)
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, 'external must be true or false'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def immutable_import_kinds(values)
|
|
35
|
+
kinds = Array(values).uniq
|
|
36
|
+
invalid_kinds = kinds.reject { |kind| ImportKind.valid?(kind) }
|
|
37
|
+
unless invalid_kinds.empty?
|
|
38
|
+
raise ArgumentError, "unknown import kinds: #{invalid_kinds.map(&:inspect).join(', ')}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
kinds.freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'edge'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
module Extraction
|
|
8
|
+
# An immutable, enumerable list of dependency edges.
|
|
9
|
+
class Graph
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
attr_reader :edges
|
|
13
|
+
|
|
14
|
+
def initialize(edges = [])
|
|
15
|
+
@edges = Array(edges).dup
|
|
16
|
+
invalid_edges = @edges.grep_v(Edge)
|
|
17
|
+
raise ArgumentError, 'graph accepts only Edge values' unless invalid_edges.empty?
|
|
18
|
+
|
|
19
|
+
validate_identifier_style
|
|
20
|
+
@edges.freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def each(&)
|
|
25
|
+
edges.each(&)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def [](*)
|
|
29
|
+
edges[*]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def size
|
|
33
|
+
edges.size
|
|
34
|
+
end
|
|
35
|
+
alias length size
|
|
36
|
+
|
|
37
|
+
def empty?
|
|
38
|
+
edges.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_a
|
|
42
|
+
edges.dup
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ==(other)
|
|
46
|
+
other.is_a?(Graph) && edges == other.edges
|
|
47
|
+
end
|
|
48
|
+
alias eql? ==
|
|
49
|
+
|
|
50
|
+
def hash
|
|
51
|
+
[self.class, edges].hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_identifier_style
|
|
57
|
+
identifiers = edges.flat_map do |edge|
|
|
58
|
+
edge.external ? [edge.source] : [edge.source, edge.target]
|
|
59
|
+
end
|
|
60
|
+
styles = identifiers.map { |identifier| absolute_identifier?(identifier) }.uniq
|
|
61
|
+
return if styles.length <= 1
|
|
62
|
+
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
'graph identifiers must be either all absolute or all project-relative'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def absolute_identifier?(identifier)
|
|
68
|
+
identifier.start_with?('/') || identifier.match?(%r{\A[A-Za-z]:/})
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
module Extraction
|
|
6
|
+
# The dependency forms the Ruby extractor records on graph edges.
|
|
7
|
+
module ImportKind
|
|
8
|
+
REQUIRE = :require
|
|
9
|
+
REQUIRE_RELATIVE = :require_relative
|
|
10
|
+
AUTOLOAD = :autoload
|
|
11
|
+
LOAD = :load
|
|
12
|
+
|
|
13
|
+
ALL = [REQUIRE, REQUIRE_RELATIVE, AUTOLOAD, LOAD].freeze
|
|
14
|
+
|
|
15
|
+
def self.valid?(value)
|
|
16
|
+
ALL.include?(value)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
# Language-neutral kernel types and operations.
|
|
5
|
+
module Common
|
|
6
|
+
FILTER_TARGETS = %i[filename path path_without_filename classname].freeze
|
|
7
|
+
FILTER_MATCHING_MODES = %i[exact partial].freeze
|
|
8
|
+
private_constant :FILTER_TARGETS, :FILTER_MATCHING_MODES
|
|
9
|
+
|
|
10
|
+
# An immutable regular-expression filter with its matching target and exclusions.
|
|
11
|
+
Filter = Data.define(:regexp, :target, :matching, :exclusions) do
|
|
12
|
+
def initialize(regexp:, target:, matching: :partial, exclusions: [])
|
|
13
|
+
regexp = immutable_regexp(regexp)
|
|
14
|
+
validate_member(target, FILTER_TARGETS, :target)
|
|
15
|
+
validate_member(matching, FILTER_MATCHING_MODES, :matching)
|
|
16
|
+
exclusions = immutable_exclusions(exclusions)
|
|
17
|
+
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def immutable_regexp(value)
|
|
24
|
+
raise ArgumentError, 'regexp must be a Regexp' unless value.is_a?(Regexp)
|
|
25
|
+
|
|
26
|
+
value.dup.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_member(value, allowed, attribute)
|
|
30
|
+
return if allowed.include?(value)
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "unknown #{attribute}: #{value.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def immutable_exclusions(values)
|
|
36
|
+
filters = Array(values).dup
|
|
37
|
+
invalid_filters = filters.grep_v(self.class)
|
|
38
|
+
unless invalid_filters.empty?
|
|
39
|
+
raise ArgumentError, 'exclusions must contain only Filter values'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
filters.freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|