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.
Files changed (149) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE +7 -0
  4. data/README.md +375 -0
  5. data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
  6. data/lib/archunit/common/assertion/violation.rb +14 -0
  7. data/lib/archunit/common/extraction/edge.rb +46 -0
  8. data/lib/archunit/common/extraction/graph.rb +73 -0
  9. data/lib/archunit/common/extraction/import_kind.rb +21 -0
  10. data/lib/archunit/common/filter.rb +46 -0
  11. data/lib/archunit/common/fluentapi/check_options.rb +48 -0
  12. data/lib/archunit/common/fluentapi/checkable.rb +70 -0
  13. data/lib/archunit/common/logging/check_logger.rb +124 -0
  14. data/lib/archunit/common/logging/logging_options.rb +57 -0
  15. data/lib/archunit/common/pattern.rb +99 -0
  16. data/lib/archunit/common/pattern_matching.rb +76 -0
  17. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
  18. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
  19. data/lib/archunit/common/projection/edge_projections.rb +50 -0
  20. data/lib/archunit/common/projection/mapped_edge.rb +26 -0
  21. data/lib/archunit/common/projection/project_cycles.rb +92 -0
  22. data/lib/archunit/common/projection/project_edges.rb +69 -0
  23. data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
  24. data/lib/archunit/common/projection/projected_edge.rb +35 -0
  25. data/lib/archunit/common/projection/projected_node.rb +36 -0
  26. data/lib/archunit/common/regex_factory.rb +96 -0
  27. data/lib/archunit/error/technical_error.rb +6 -0
  28. data/lib/archunit/error/user_error.rb +6 -0
  29. data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
  30. data/lib/archunit/extraction/extract_dependencies.rb +61 -0
  31. data/lib/archunit/extraction/extract_graph.rb +89 -0
  32. data/lib/archunit/extraction/extract_imports.rb +197 -0
  33. data/lib/archunit/extraction/locate_project.rb +80 -0
  34. data/lib/archunit/extraction/resolve_import.rb +76 -0
  35. data/lib/archunit/extraction/resolved_import.rb +46 -0
  36. data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
  37. data/lib/archunit/files/assertion/cycle_free.rb +58 -0
  38. data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
  39. data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
  40. data/lib/archunit/files/assertion/matching_files.rb +87 -0
  41. data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
  42. data/lib/archunit/files/extraction/file_info.rb +46 -0
  43. data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
  44. data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
  45. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
  46. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
  47. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
  48. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
  49. data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
  50. data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
  51. data/lib/archunit/files/fluentapi/files.rb +31 -0
  52. data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
  53. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
  54. data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
  55. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
  56. data/lib/archunit/graph/fluentapi/graph.rb +29 -0
  57. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
  58. data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
  59. data/lib/archunit/graph/projection/collapse_node.rb +33 -0
  60. data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
  61. data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
  62. data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
  63. data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
  64. data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
  65. data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
  66. data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
  67. data/lib/archunit/graph/projection/node_selection.rb +111 -0
  68. data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
  69. data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
  70. data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
  71. data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
  72. data/lib/archunit/graph/rendering/escaping.rb +31 -0
  73. data/lib/archunit/graph/rendering/export_report.rb +29 -0
  74. data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
  75. data/lib/archunit/graph/rendering/html_document.rb +46 -0
  76. data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
  77. data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
  78. data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
  79. data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
  80. data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
  81. data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
  82. data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
  83. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
  84. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
  85. data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
  86. data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
  87. data/lib/archunit/layers/fluentapi/layers.rb +29 -0
  88. data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
  89. data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
  90. data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
  91. data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
  92. data/lib/archunit/metrics/calculation/count.rb +45 -0
  93. data/lib/archunit/metrics/calculation/distance.rb +84 -0
  94. data/lib/archunit/metrics/calculation/lcom.rb +103 -0
  95. data/lib/archunit/metrics/calculation/metric.rb +52 -0
  96. data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
  97. data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
  98. data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
  99. data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
  100. data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
  101. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
  102. data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
  103. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
  104. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
  105. data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
  106. data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
  107. data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
  108. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
  109. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
  110. data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
  111. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
  112. data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
  113. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
  114. data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
  115. data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
  116. data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
  117. data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
  118. data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
  119. data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
  120. data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
  121. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
  122. data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
  123. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
  124. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
  125. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
  126. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
  127. data/lib/archunit/slices/fluentapi/slices.rb +29 -0
  128. data/lib/archunit/slices/projection/slice_projection.rb +76 -0
  129. data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
  130. data/lib/archunit/slices/uml/export_diagram.rb +70 -0
  131. data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
  132. data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
  133. data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
  134. data/lib/archunit/testing/assert_passes.rb +24 -0
  135. data/lib/archunit/testing/assertion_failure.rb +22 -0
  136. data/lib/archunit/testing/color_utils.rb +66 -0
  137. data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
  138. data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
  139. data/lib/archunit/testing/minitest_adapter.rb +33 -0
  140. data/lib/archunit/testing/result_factory.rb +93 -0
  141. data/lib/archunit/testing/rspec_adapter.rb +74 -0
  142. data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
  143. data/lib/archunit/testing/test_result.rb +26 -0
  144. data/lib/archunit/testing/test_violation.rb +23 -0
  145. data/lib/archunit/testing/violation_factory.rb +138 -0
  146. data/lib/archunit/testing.rb +39 -0
  147. data/lib/archunit/version.rb +5 -0
  148. data/lib/archunit.rb +110 -0
  149. 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
+ [![CI](https://github.com/LukasNiessen/ArchUnitRuby/actions/workflows/ci.yml/badge.svg)](https://github.com/LukasNiessen/ArchUnitRuby/actions/workflows/ci.yml)
7
+ [![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-e4493f)](https://lukasniessen.github.io/ArchUnitRuby/)
8
+ [![Gem version](https://img.shields.io/gem/v/archunit.svg)](https://rubygems.org/gems/archunit)
9
+ [![Gem downloads](https://img.shields.io/gem/dt/archunit.svg)](https://rubygems.org/gems/archunit)
10
+ [![Ruby 3.3+](https://img.shields.io/badge/Ruby-3.3%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
+ [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitRuby.svg)](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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Common
5
+ module Assertion
6
+ # Marker base for data-only architecture rule failures.
7
+ class Violation
8
+ def initialize
9
+ freeze
10
+ end
11
+ end
12
+ end
13
+ end
14
+ 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