archunit 0.0.1 → 1.0.0

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +290 -0
  3. data/CHANGELOG.md +45 -0
  4. data/README.md +389 -90
  5. data/assets/logo-rounded.png +0 -0
  6. data/lib/archunit/common/extraction/dependency_evidence.rb +44 -0
  7. data/lib/archunit/common/extraction/edge.rb +7 -2
  8. data/lib/archunit/common/extraction/import_kind.rb +2 -1
  9. data/lib/archunit/common/extraction/parse_diagnostic.rb +23 -0
  10. data/lib/archunit/common/fluentapi/check_options.rb +43 -2
  11. data/lib/archunit/common/fluentapi/checkable.rb +25 -1
  12. data/lib/archunit/common/logging/check_logger.rb +8 -0
  13. data/lib/archunit/common/logging/inspection.rb +59 -0
  14. data/lib/archunit/common/logging/logging_options.rb +5 -0
  15. data/lib/archunit/common/pattern.rb +18 -8
  16. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +35 -19
  17. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +30 -10
  18. data/lib/archunit/error/source_parse_error.rb +38 -0
  19. data/lib/archunit/extraction/arch_ignore.rb +91 -0
  20. data/lib/archunit/extraction/conservative_local_flow.rb +100 -0
  21. data/lib/archunit/extraction/constant_index.rb +110 -0
  22. data/lib/archunit/extraction/constant_visitor.rb +129 -0
  23. data/lib/archunit/extraction/discover_gemspec_load_paths.rb +96 -0
  24. data/lib/archunit/extraction/enumerate_source_files.rb +23 -8
  25. data/lib/archunit/extraction/extract_dependencies.rb +82 -14
  26. data/lib/archunit/extraction/extract_graph.rb +56 -12
  27. data/lib/archunit/extraction/extract_imports.rb +60 -124
  28. data/lib/archunit/extraction/extraction_profile.rb +105 -0
  29. data/lib/archunit/extraction/import_resolver.rb +56 -0
  30. data/lib/archunit/extraction/import_visitor.rb +104 -0
  31. data/lib/archunit/extraction/local_string_tracker.rb +80 -0
  32. data/lib/archunit/extraction/resolve_import.rb +5 -3
  33. data/lib/archunit/extraction/resolve_load_paths.rb +93 -0
  34. data/lib/archunit/extraction/resolved_import.rb +5 -2
  35. data/lib/archunit/extraction/validate_extraction_paths.rb +37 -0
  36. data/lib/archunit/files/extraction/extract_file_info.rb +14 -5
  37. data/lib/archunit/files/extraction/file_info.rb +1 -2
  38. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +2 -0
  39. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +2 -0
  40. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +6 -0
  41. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +6 -0
  42. data/lib/archunit/files/fluentapi/file_condition_builder.rb +12 -0
  43. data/lib/archunit/files/fluentapi/file_rule_support.rb +8 -1
  44. data/lib/archunit/files/fluentapi/files.rb +6 -0
  45. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +14 -0
  46. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +2 -0
  47. data/lib/archunit/graph/fluentapi/graph.rb +5 -0
  48. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +44 -0
  49. data/lib/archunit/graph/projection/aggregate_edges.rb +4 -2
  50. data/lib/archunit/graph/projection/graph_query_options.rb +1 -2
  51. data/lib/archunit/graph/projection/graph_report_edge.rb +12 -2
  52. data/lib/archunit/graph/rendering/json_renderer.rb +3 -1
  53. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +4 -0
  54. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +4 -0
  55. data/lib/archunit/layers/fluentapi/layered_architecture.rb +5 -0
  56. data/lib/archunit/layers/fluentapi/layers.rb +5 -0
  57. data/lib/archunit/metrics/extraction/extract_distance_info.rb +1 -2
  58. data/lib/archunit/metrics/extraction/extract_project_info.rb +6 -6
  59. data/lib/archunit/metrics/extraction/metric_info.rb +2 -4
  60. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +2 -4
  61. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +2 -0
  62. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +4 -1
  63. data/lib/archunit/metrics/fluentapi/logged_metric.rb +23 -0
  64. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +4 -1
  65. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +2 -0
  66. data/lib/archunit/metrics/fluentapi/metric_selection.rb +18 -2
  67. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +2 -1
  68. data/lib/archunit/metrics/fluentapi/metrics.rb +5 -0
  69. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +15 -0
  70. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +1 -0
  71. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +1 -0
  72. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +2 -0
  73. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +8 -0
  74. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +12 -0
  75. data/lib/archunit/slices/fluentapi/slices.rb +5 -0
  76. data/lib/archunit/testing/assert_passes.rb +4 -0
  77. data/lib/archunit/testing/result_factory.rb +16 -4
  78. data/lib/archunit/testing/rspec_adapter.rb +1 -2
  79. data/lib/archunit/testing.rb +7 -3
  80. data/lib/archunit/version.rb +1 -1
  81. data/lib/archunit.rb +9 -0
  82. metadata +20 -2
data/README.md CHANGED
@@ -1,66 +1,85 @@
1
- # ArchUnitRuby
1
+ # ArchUnitRuby - Architecture Testing
2
2
 
3
- Architecture testing for Ruby. Part of **ArchUnitEverything**: one recognizable testing library
4
- for each programming language.
3
+ <p align="center" name="top">
4
+ <img src="assets/logo-rounded.png" width="180" alt="ArchUnitRuby logo">
5
+ </p>
5
6
 
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)
7
+ <p align="center">
8
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
9
+ <a href="https://github.com/LukasNiessen/ArchUnitRuby/actions/workflows/ci.yml"><img alt="Build & tests" src="https://img.shields.io/github/actions/workflow/status/LukasNiessen/ArchUnitRuby/ci.yml?branch=main&amp;label=build%20%26%20tests"></a>
10
+ <a href="https://github.com/LukasNiessen/ArchUnitRuby"><img alt="GitHub stars" src="https://img.shields.io/github/stars/LukasNiessen/ArchUnitRuby.svg"></a><br>
11
+ <a href="https://clickgems.clickhouse.com/dashboard/archunit"><img alt="Gem downloads" src="https://img.shields.io/gem/dt/archunit.svg"></a>
12
+ <a href="https://www.ruby-lang.org/"><img alt="Ruby 3.3+" src="https://img.shields.io/badge/Ruby-3.3%2B-CC342D?logo=ruby&amp;logoColor=white"></a>
13
+ </p>
14
+
15
+ Enforce architecture rules in Ruby projects. Check dependency directions, detect circular
16
+ dependencies, enforce naming and location conventions, measure code quality, and generate
17
+ architecture reports as ordinary Ruby tests.
18
+
19
+ _Inspired by the amazing ArchUnit library, but not affiliated with ArchUnit._
20
+
21
+ [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)
13
22
 
14
23
  ArchUnitRuby turns a Ruby codebase into a dependency graph and lets you test that graph with rules
15
24
  that read like English:
16
25
 
17
- ```ruby
26
+ ~~~ ruby
18
27
  ArchUnit.project_files
19
28
  .in_folder('app/api/**')
20
29
  .should_not.depend_on_files
21
30
  .in_folder('app/database/**')
22
- ```
31
+ ~~~
23
32
 
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.
33
+ ArchUnitRuby provides file, layer, slice, graph-reporting, and metric APIs with a documented 1.0
34
+ compatibility baseline. It is tested on Ruby 3.3, 3.4, and 4.0 on Linux and Ruby 4.0 on Windows.
35
+ The gem is named [`archunit`](https://rubygems.org/gems/archunit); check RubyGems for published-version
36
+ availability, since a development checkout can be ahead of the published gem.
27
37
 
28
- Siblings: [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS) and
29
- [ArchUnitPython](https://github.com/LukasNiessen/ArchUnitPython).
38
+ ArchUnitRuby follows the shared ArchUnitEverything architecture and fluent-API standard used by
39
+ its siblings [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS) and
40
+ [ArchUnitPython](https://github.com/LukasNiessen/ArchUnitPython), adapted where Ruby idioms differ.
30
41
 
31
- ## Documentation
42
+ The [documentation site](https://lukasniessen.github.io/ArchUnitRuby/) combines this guide, a
43
+ curated [API guide](https://lukasniessen.github.io/ArchUnitRuby/file.API.html), and a searchable,
44
+ source-generated reference. The same site is rebuilt and link-validated in CI before deployment
45
+ from `main`, so the published reference follows the repository.
32
46
 
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.
47
+ ## ⚡ 5 min Quickstart
36
48
 
37
- ## Install
49
+ ### Installation
38
50
 
39
- ArchUnitRuby requires Ruby 3.3 or newer. Add it to your test dependencies:
51
+ ArchUnitRuby requires Ruby 3.3 or newer. These instructions target the 1.0 series. Add it to your
52
+ test dependencies once the target version is published:
40
53
 
41
- ```ruby
54
+ ~~~ ruby
42
55
  # Gemfile
43
56
  group :test do
44
- gem 'archunit', '~> 0.0.1'
57
+ gem 'archunit', '~> 1.0.0'
45
58
  end
46
- ```
59
+ ~~~
47
60
 
48
61
  Then install it:
49
62
 
50
- ```bash
63
+ ~~~ bash
51
64
  bundle install
52
- ```
65
+ ~~~
66
+
67
+ Or install it directly with `gem install archunit --version '~> 1.0.0'`.
53
68
 
54
- Or install it directly with `gem install archunit`.
69
+ [ClickGems download analytics](https://clickgems.clickhouse.com/dashboard/archunit) show trends by
70
+ date, gem version, Ruby version, system, and country. Download totals count package fetches rather
71
+ than unique users, so repeated installs and automated clients may contribute to the total. The
72
+ [RubyGems API](https://rubygems.org/api/v1/gems/archunit.json) provides the
73
+ current raw count and ClickGems provides the historical breakdown.
55
74
 
56
75
  RSpec and Minitest integrations are optional; ArchUnitRuby does not install either test framework
57
76
  for you.
58
77
 
59
- ## Your first rule
78
+ ### Add tests
60
79
 
61
80
  Create `spec/architecture_spec.rb`:
62
81
 
63
- ```ruby
82
+ ~~~ ruby
64
83
  require 'archunit'
65
84
 
66
85
  RSpec.describe 'architecture' do
@@ -71,23 +90,83 @@ RSpec.describe 'architecture' do
71
90
  expect(rule).to pass
72
91
  end
73
92
  end
74
- ```
93
+ ~~~
75
94
 
76
95
  Run it like any other specification:
77
96
 
78
- ```bash
97
+ ~~~ bash
79
98
  bundle exec rspec spec/architecture_spec.rb
80
- ```
99
+ ~~~
100
+
101
+ ### CI Integration
102
+
103
+ Architecture specifications run with the rest of the test suite, so no dedicated CI integration
104
+ is required:
105
+
106
+ ~~~ yaml
107
+ # GitHub Actions
108
+ - name: Run architecture tests
109
+ run: bundle exec rspec spec/architecture_spec.rb
110
+ ~~~
111
+
112
+ ## 🚐 Setup
113
+
114
+ ### Explain why a rule exists
115
+
116
+ Append `.because(...)` to a completed file, layer, slice, or metric rule:
117
+
118
+ ~~~ ruby
119
+ rule = ArchUnit.project_files.in_folder('app/api/**')
120
+ .should_not.depend_on_files.in_folder('app/database/**')
121
+ .because('Keep persistence out of the API')
122
+
123
+ expect(rule).to pass
124
+ ~~~
125
+
126
+ The explanation appears once in a failed RSpec, Minitest, or `ArchUnit.assert_passes` assertion.
127
+ It is available as `rule.rationale`. Calling `.because(...)` returns a distinct frozen rule;
128
+ the original rule and direct `check` results are unchanged. Reasons must be non-blank strings.
129
+ Add the explanation after all selectors and modifiers.
130
+
131
+ ### Project Location
81
132
 
82
133
  The project locator is optional. With no argument, ArchUnitRuby searches from the current directory
83
134
  for a `Gemfile` or gemspec. Pass a directory or either marker file when analyzing another project:
84
135
 
85
- ```ruby
136
+ ~~~ ruby
86
137
  ArchUnit.project_files('/workspace/my_app')
87
138
  ArchUnit.project_files('/workspace/my_app/Gemfile')
88
- ```
139
+ ~~~
89
140
 
90
- ## The fluent grammar
141
+ Source discovery skips symlinked files and directories, including project-local aliases. This
142
+ avoids duplicate file identities and unintentionally parsing linked source outside the project.
143
+ Direct file-information extraction also checks the canonical source path against the project root.
144
+
145
+ ### Project-wide exclusions
146
+
147
+ Add a `.archignore` at the detected project root to keep generated or intentionally out-of-scope
148
+ Ruby files out of dependency graphs and file-based metrics:
149
+
150
+ ~~~ text
151
+ # Architecture exclusions (not a full gitignore parser)
152
+ /db/migrate/
153
+ generated/
154
+ **/*_generated.rb
155
+ ~~~
156
+
157
+ Blank lines and full-line `#` comments are ignored. Bare names match at any directory depth;
158
+ patterns containing `/` are project-relative, and a leading `/` anchors a pattern to the root.
159
+ A trailing `/` matches directories and their descendants, not similarly named files. Windows
160
+ separators are normalized. Only the root file is read; nested `.archignore` files are not inherited.
161
+
162
+ These patterns combine with the built-in exclusions or explicit `exclude_patterns:`; `[]` disables
163
+ the defaults, not `.archignore`. Effective pattern changes automatically select a fresh cached graph.
164
+ Source-code edits still require `clear_cache: true` in a long-lived process. The file is limited to
165
+ 1 MiB, must be a regular non-symlink file, and is decoded as UTF-8 with invalid bytes replaced.
166
+ Negation (`!pattern`), NUL characters, and a bare `/` are rejected with `ArchUnit::UserError`.
167
+ No configuration or application code is executed.
168
+
169
+ ### Fluent Grammar
91
170
 
92
171
  Every rule is built left to right from the same small grammar:
93
172
 
@@ -103,12 +182,12 @@ Every rule is built left to right from the same small grammar:
103
182
  Building a rule is lazy and does not scan the filesystem. `check`, `measure`, snapshot/report
104
183
  terminals, and export terminals perform the work. Builders are immutable, so a scope can be reused:
105
184
 
106
- ```ruby
185
+ ~~~ ruby
107
186
  services = ArchUnit.project_files.in_folder('app/services/**')
108
187
 
109
188
  cycle_rule = services.should.have_no_cycles
110
189
  database_rule = services.should_not.depend_on_files.in_folder('app/database/**')
111
- ```
190
+ ~~~
112
191
 
113
192
  String patterns are anchored globs. `*` stays inside one path segment, `**` crosses directories,
114
193
  and `?` matches one non-separator character. Most selectors also accept regular expressions;
@@ -118,16 +197,18 @@ Paths are project-relative and normalized to `/` separators.
118
197
  A scope matching zero files returns `EmptyTestViolation`; it does not silently pass. Opt out only
119
198
  when an empty result is genuinely valid:
120
199
 
121
- ```ruby
200
+ ~~~ ruby
122
201
  rule.check(ArchUnit::CheckOptions.new(allow_empty_tests: true))
123
- ```
202
+ ~~~
203
+
204
+ ## 🐣 Features
124
205
 
125
- ## Files
206
+ ### File Rules
126
207
 
127
208
  File rules cover cycles, naming, location, internal dependencies, external modules, and custom
128
209
  source predicates:
129
210
 
130
- ```ruby
211
+ ~~~ ruby
131
212
  rules = [
132
213
  ArchUnit.project_files.in_path('lib/**/*.rb').should.have_no_cycles,
133
214
  ArchUnit.project_files.in_folder('app/services/**')
@@ -137,24 +218,24 @@ rules = [
137
218
  ]
138
219
 
139
220
  rules.each { |rule| ArchUnit.assert_passes(rule) }
140
- ```
221
+ ~~~
141
222
 
142
223
  A custom predicate receives an immutable `FileInfo` with `path`, `name`, `extension`, `directory`,
143
224
  complete `content`, and non-blank `lines_of_code`:
144
225
 
145
- ```ruby
226
+ ~~~ ruby
146
227
  rule = ArchUnit.project_files.in_folder('app/services/**')
147
228
  .should.adhere_to(
148
229
  ->(file) { file.lines_of_code < 300 },
149
230
  'services must stay below 300 non-blank lines'
150
231
  )
151
- ```
232
+ ~~~
152
233
 
153
- ## Layers
234
+ ### Layer Dependencies
154
235
 
155
236
  Named layers express an allowlist or blocklist over groups of files:
156
237
 
157
- ```ruby
238
+ ~~~ ruby
158
239
  rule = ArchUnit.project_layers
159
240
  .layer('api').defined_by('app/api/**/*.rb')
160
241
  .layer('services').defined_by('app/services/**/*.rb')
@@ -164,44 +245,44 @@ rule = ArchUnit.project_layers
164
245
  .where_layer('database').may_only_depend_on_layers
165
246
 
166
247
  expect(rule).to pass
167
- ```
248
+ ~~~
168
249
 
169
250
  Dependencies within one layer are always allowed. Edges with an unassigned endpoint are ignored.
170
251
  Calling `may_only_depend_on_layers` without targets seals a layer; `may_not_depend_on_layers`
171
252
  requires at least one forbidden target.
172
253
 
173
- ## Slices and PlantUML
254
+ ### Architecture Slices and PlantUML
174
255
 
175
256
  Slices group files by one captured path segment and preserve every concrete dependency as evidence:
176
257
 
177
- ```ruby
258
+ ~~~ ruby
178
259
  slices = ArchUnit.project_slices.defined_by('lib/my_app/(**)/')
179
260
  rule = slices.should_not.contain_dependency('api', 'database')
180
261
 
181
262
  expect(rule).to pass
182
- ```
263
+ ~~~
183
264
 
184
265
  `(**)` is the slice capture. `defined_by_regex` uses the first regular-expression capture instead.
185
266
 
186
267
  A checked-in PlantUML component diagram can also be the architecture contract:
187
268
 
188
- ```ruby
269
+ ~~~ ruby
189
270
  rule = slices.should
190
271
  .ignoring_external_slices
191
272
  .adhere_to_diagram_in_file('docs/architecture.puml')
192
273
 
193
274
  expect(rule).to pass
194
- ```
275
+ ~~~
195
276
 
196
277
  The supported subset recognizes components, directed dependencies, comments, and `@startuml` /
197
278
  `@enduml`. Use `to_plantuml` or `export_as_plantuml(path)` to generate a diagram from the real graph.
198
279
 
199
- ## Dependency graph reports
280
+ ### Dependency Graph Reports
200
281
 
201
282
  Graph reporting builds one immutable snapshot and renders it consistently as DOT, Mermaid, D2, CSV,
202
283
  JSON, or self-contained HTML:
203
284
 
204
- ```ruby
285
+ ~~~ ruby
205
286
  report = ArchUnit.project_graph
206
287
  .include_external_dependencies
207
288
  .focus_on('app/services/**', 2)
@@ -210,17 +291,17 @@ report = ArchUnit.project_graph
210
291
 
211
292
  puts report.summary.node_count
212
293
  report.export_as_html('reports/services.html')
213
- ```
294
+ ~~~
214
295
 
215
296
  Queries include `focus_on`, `reachable_from`, and `dependents_of`. Collapse by folder depth or a
216
297
  regular-expression replacement. Every format has an in-memory `to_<format>` and an
217
298
  `export_as_<format>(path)` terminal.
218
299
 
219
- ## Metrics
300
+ ### Code Metrics
220
301
 
221
302
  Metric scopes select files and Ruby classes before measurement or assertion:
222
303
 
223
- ```ruby
304
+ ~~~ ruby
224
305
  services = ArchUnit.metrics
225
306
  .in_path('app/services/**/*.rb')
226
307
  .for_classes_matching('*Service')
@@ -230,7 +311,7 @@ cohesion_rule = services.lcom.lcom4.should_be(1)
230
311
  distance_rule = services.distance.instability.should_be_below(0.8)
231
312
 
232
313
  [size_rule, cohesion_rule, distance_rule].each { |rule| ArchUnit.assert_passes(rule) }
233
- ```
314
+ ~~~
234
315
 
235
316
  Count metrics cover class methods and fields plus file lines, statements, imports, classes, and
236
317
  top-level functions. Cohesion includes LCOM96a, LCOM96b, LCOM1-5, and LCOM*. Dependency-derived
@@ -240,61 +321,63 @@ distance. Zone guards detect the conventional zones of pain and uselessness.
240
321
  Use `measure` for immutable numeric results, `custom_metric` for a calculation over `ClassInfo`, and
241
322
  `export_as_html` for an offline metrics report:
242
323
 
243
- ```ruby
324
+ ~~~ ruby
244
325
  services.count.export_as_html('reports/service-counts')
245
- ```
326
+ ~~~
246
327
 
247
328
  The threshold vocabulary is intentionally limited to `should_be_below`, `should_be_above`,
248
329
  `should_be`, `should_be_below_or_equal`, `should_be_above_or_equal`, and `should_satisfy`.
249
330
 
250
- ## Pattern exclusions
331
+ ### Pattern Exclusions
251
332
 
252
333
  Every selector accepts `except:` in the same call. A plain pattern or array uses the parent
253
334
  selector's context, including filenames for path and folder selectors:
254
335
 
255
- ```ruby
336
+ ~~~ ruby
256
337
  scope = ArchUnit.project_files.in_path(
257
338
  'app/**/*.rb',
258
339
  except: ['app/generated/**', 'schema.rb']
259
340
  )
260
- ```
341
+ ~~~
261
342
 
262
343
  Use explicit targets when needed. Supported keys are `in_path`, `in_folder`, `with_name`, and
263
344
  `for_classes_matching`:
264
345
 
265
- ```ruby
346
+ ~~~ ruby
266
347
  scope = ArchUnit.metrics.in_path(
267
348
  'app/**/*.rb',
268
349
  except: { in_folder: 'app/generated', with_name: '*_spec.rb' }
269
350
  )
270
- ```
351
+ ~~~
271
352
 
272
- ## Results and test frameworks
353
+ ## 🐹 Use Cases
354
+
355
+ ### Results and Test Frameworks
273
356
 
274
357
  `check` returns an array of structured violations. Architecture disagreement is data, not an
275
358
  exception:
276
359
 
277
- ```ruby
360
+ ~~~ ruby
278
361
  violations = rule.check
279
362
  violations.each { |violation| puts violation.class }
280
- ```
363
+ ~~~
281
364
 
282
365
  Translate that result into a test failure at the boundary that suits your suite:
283
366
 
284
- ```ruby
367
+ ~~~ ruby
285
368
  expect(rule).to pass # RSpec
286
369
  assert_passes(rule) # Minitest test case
287
370
  ArchUnit.assert_passes(rule) # Framework-neutral
288
- ```
371
+ ~~~
289
372
 
290
373
  `ArchUnit.format_violations` and `ResultFactory` provide stable human-readable output. All
291
374
  violations retain the concrete dependency, file, layer, slice, or metric evidence that caused them.
292
375
 
293
- ## Per-check logging
376
+ ## 📝 Debug Logging & Configuration
294
377
 
295
378
  Logging is off by default and belongs to one check; there is no process-global configuration:
296
379
 
297
- ```ruby
380
+ ~~~ ruby
298
381
  logging = ArchUnit::LoggingOptions.new(
299
382
  level: :debug,
300
383
  output_directory: 'tmp/archunit-logs',
@@ -302,13 +385,54 @@ logging = ArchUnit::LoggingOptions.new(
302
385
  )
303
386
 
304
387
  violations = rule.check(ArchUnit::CheckOptions.new(logging: logging))
305
- ```
388
+ ~~~
306
389
 
307
390
  Levels are `debug`, `info`, `warn`, and `error`. The fixed events cover check start/end, progress,
308
391
  violations, and metric evidence. `io:` defaults to `$stderr`, accepts any writable stream, and may be
309
392
  `nil`. File output creates missing directories and writes timestamped `archunit-*.log` files.
310
393
 
311
- ## What ArchUnitRuby extracts
394
+ At `:debug`, inspection includes the project root, each discovered file, every dependency with
395
+ its import kind and external flag, selected files, and all metric calculations (including passing
396
+ values). It observes the same graph on both cold and cached checks. Custom metrics run once.
397
+ Check options and returned violations retain their existing behavior.
398
+
399
+ | Level | Included output |
400
+ | --- | --- |
401
+ | `:debug` | Graph inspection, selections, metric values, and everything below |
402
+ | `:info` | Check lifecycle and progress, violations, technical errors |
403
+ | `:warn` | Violations and technical errors |
404
+ | `:error` | Technical errors |
405
+
406
+ Example debug lines (timestamp prefixes omitted):
407
+
408
+ ~~~ text
409
+ [DEBUG] inspect: discovered file: "lib/service.rb"
410
+ [DEBUG] inspect: dependency: "lib/service.rb" -> "json" (external=true, kinds=[:require])
411
+ [DEBUG] inspect: selected file: "lib/service.rb"
412
+ [DEBUG] log metric: method_count=3 [lib/service.rb:Service]
413
+ ~~~
414
+
415
+ For file-only CI artifacts, use `io: nil` with `output_directory:` and archive that directory.
416
+ Logging sink errors still propagate. Debug details are produced lazily; checks with logging
417
+ disabled create no output or log files.
418
+
419
+ ### Readable, colored failure reports
420
+
421
+ The same formatted, numbered reports work with RSpec, Minitest, or standalone checks:
422
+
423
+ ~~~ ruby
424
+ puts ArchUnit.format_violations(violations) # Detect terminal support
425
+ puts ArchUnit.format_violations(violations, color: true) # Force ANSI colors
426
+ puts ArchUnit.format_violations(violations, color: false) # Plain CI/file output
427
+ ~~~
428
+
429
+ Failure headings are bold red, violation headings yellow, and successful results green. Reports
430
+ include dependency, file, or metric evidence. Automatic color detection respects `NO_COLOR`,
431
+ `TERM=dumb`, and non-terminal output. Log files remain plain text for searching and CI artifacts.
432
+
433
+ ## 🕵️ Technical Deep Dive
434
+
435
+ ### What ArchUnitRuby Extracts
312
436
 
313
437
  ArchUnitRuby uses Prism and statically recognizes:
314
438
 
@@ -323,17 +447,121 @@ Project dependencies use normalized, project-relative paths. Standard-library an
323
447
  retain the module name written in source. Inline or immediately preceding ignore directives can
324
448
  suppress known compatibility imports:
325
449
 
326
- ```ruby
450
+ ~~~ ruby
327
451
  require 'legacy/client' # archunit: ignore legacy/client
328
452
 
329
453
  # archunit: ignore experimental/plugin
330
454
  require 'experimental/plugin'
331
- ```
455
+ ~~~
456
+
457
+ Preceding local assignments to literal strings are also recognized for all four import forms:
458
+
459
+ ~~~ ruby
460
+ path = '../retrieval/store'
461
+ require_relative path
462
+ ~~~
463
+
464
+ Inference follows assignment order within straight-line lexical scopes. Reassignment replaces the
465
+ value; aliases, mutation, unknown calls receiving the value, reflection, and unsupported control
466
+ flow invalidate it. Values are not carried across branches, loops, exception handlers, or captured
467
+ blocks. Once a closure or reflective binding can access an enclosing scope, local inference remains
468
+ disabled for that scope. Independent literal assignments inside a method or block can still resolve. Runtime values
469
+ such as `ENV.fetch('PLUGIN_PATH')`, interpolation, and method results remain unresolved. No source,
470
+ Gemfile, gemspec, initializer, or `const_missing` hook is executed.
471
+
472
+ ### Constant References (Opt-in)
473
+
474
+ For autoloaded applications, enable dependencies from known project constants alongside imports:
475
+
476
+ ~~~ ruby
477
+ options = ArchUnit::CheckOptions.new(dependency_analysis: :imports_and_constants)
478
+
479
+ # Detects Retrieval::Store.new even without require_relative.
480
+ rule = ArchUnit.project_files
481
+ .in_path('app/api/**')
482
+ .should_not.depend_on_files.in_path('app/retrieval/**')
483
+ violations = rule.check(options)
484
+
485
+ report = ArchUnit.project_graph.with_check_options(options)
486
+ puts report.to_json
487
+ puts ArchUnit.project_slices.defined_by('app/(**)/').to_plantuml(options)
488
+ values = ArchUnit.metrics.distance.instability.measure(options)
489
+ ~~~
490
+
491
+ The default remains `dependency_analysis: :imports`. Expanded mode indexes declarations from the
492
+ same included source files, then resolves references to definition files. Files remain graph nodes.
493
+ It supports lexical nesting, compact namespaces, root-qualified names, and explicit superclass,
494
+ `include`, `prepend`, and `extend` references. Nested `module A; module B` includes `A` in lexical
495
+ lookup; top-level `module A::B` does not. A nearer namespace shadows a root namespace even when its
496
+ requested child is missing.
497
+
498
+ Ordinary `=` and conditional `||=` constant declarations are indexed, including qualified and
499
+ root-qualified forms. A conditional declaration records a possible definition file, not proof that
500
+ the assignment executes or its resulting value. Multiple candidate files stay ambiguous. Other
501
+ compound writes (`&&=`, operators) and multiple-assignment declarations are not inferred as new
502
+ definition owners; unsupported targets remain unresolved rather than inventing ownership.
503
+
504
+ References with multiple definition files (reopened classes/modules), missing definitions, and
505
+ dynamic definitions are omitted, never assigned to an arbitrary file. `Alias = Original` defines
506
+ `Alias` in its own file and records the reference to `Original`; lookup through aliases such as
507
+ `Alias::Child` is not followed. Inherited constant members and singleton-class lexical lookup are
508
+ not modeled. Unqualified root fallback is conservatively suppressed in scopes with an explicit
509
+ superclass, mixins, or dynamic constant operations. Use explicit `::Namespace::Constant` references
510
+ or import rules where those limits matter. Static analysis does not establish runtime load order
511
+ or whether a conditional use executes. Qualified declaration owners are stabilized before lookup;
512
+ if they do not stabilize within eight index passes, the project contributes imports only and its
513
+ constant uses are counted as unresolved.
514
+
515
+ In expanded mode, an import and a constant use between the same files produce one logical edge.
516
+ Its `import_kinds`
517
+ includes `:constant_reference`; `edge.evidence` and JSON graph exports retain the referenced name,
518
+ source span, and definition line for constant uses. Import evidence retains its call span. CSV,
519
+ HTML, and diagram reports use the same merged graph; CSV/HTML distinguish dependency kinds.
520
+ `# archunit: ignore` continues to suppress imports only, not constant references. Import-only mode
521
+ leaves `edge.evidence` empty for compatibility with existing edge-value comparisons;
522
+ `ArchUnit::Extraction.extract_imports` retains call locations in both modes.
523
+
524
+ For diagnostics, pass an `ArchUnit::Extraction::ExtractionProfile` to
525
+ `ArchUnit::Extraction.extract_graph(root, options: options, profile: profile)`. Its counters include
526
+ `unresolved_imports`, `unresolved_constant_references`, and `ambiguous_constant_references`; unknown
527
+ third-party constants do not become architecture violations. These counts describe a cold analysis;
528
+ a cache hit does not rerun diagnostics. Included malformed Ruby now raises
529
+ `ArchUnit::SourceParseError < ArchUnit::TechnicalError` in both modes and in AST metrics, rather
530
+ than silently returning a partial graph or zero counts. Its immutable `diagnostics` contain
531
+ `path`, one-based `line` and `column`, and the parser `message`; the extraction profile increments
532
+ `malformed_files`. Fix or explicitly exclude invalid files using the project source policy.
533
+ Fluent rule selectors do not limit graph discovery. Failed graph builds are not cached; after
534
+ editing previously cached valid source, the normal explicit `clear_cache: true` policy still applies.
535
+ This intentionally changes the 0.0.1 prototype's permissive behavior in the 1.0 API.
536
+
537
+ Expanded analysis uses a bounded Prism index and adds no runtime dependency. The design spike
538
+ considered [RubyDEX](https://github.com/Shopify/rubydex), whose semantic index is broader but adds a
539
+ Rust/native extension and platform packaging requirements. Reusing our existing Prism parser keeps
540
+ the Ruby 3.3/3.4/4.0 Windows/Linux support contract; each included source is parsed once per graph
541
+ build. A later semantic backend can broaden the documented lookup subset. Lookup fixtures follow
542
+ the [Ruby constant-scope documentation](https://docs.ruby-lang.org/en/master/syntax/modules_and_classes_rdoc.html#label-Constants).
543
+
544
+ Repositories containing multiple gems are discovered statically: every `*.gemspec` beneath the
545
+ project root contributes its adjacent existing `lib` directory to feature resolution. Gemspecs are
546
+ never evaluated. Add non-standard source roots explicitly through the per-check options bag; paths
547
+ are relative to the project root, must remain inside it, and use the order given after the normal
548
+ top-level `lib` and project-root search locations:
549
+
550
+ ~~~ ruby
551
+ options = ArchUnit::CheckOptions.new(
552
+ load_paths: ['components/billing/source', 'plugins/search/lib']
553
+ )
554
+
555
+ violations = rule.check(options)
556
+ report = ArchUnit.project_graph.with_check_options(options)
557
+ ~~~
332
558
 
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.
559
+ Load-path choices and dependency-analysis mode affect graph caching. Equivalent normalized choices
560
+ reuse a cached graph, while a different mode or load-path set builds a separate graph. Use
561
+ `clear_cache: true` after changing files, constant definitions, or gemspec
562
+ layout within one process.
335
563
 
336
- ## Executable examples
564
+ ## 🐲 Example Repository
337
565
 
338
566
  The [ArchUnitRuby RAG test repository](https://github.com/TristanKruse/ArchUnitRuby-TestRepo-RAG)
339
567
  is a small layered retrieval-augmented-generation application with two deliberate architecture
@@ -344,32 +572,103 @@ ArchUnitRuby also dogfoods itself in `spec/architecture_spec.rb`: `common` is is
344
572
  modules cannot depend on one another, implementation files cannot depend on the public surface, and
345
573
  the complete library graph must remain cycle-free.
346
574
 
347
- ## Development
575
+ ## 🦊 Contributing
348
576
 
349
- ```bash
577
+ Start with [CONTRIBUTING.md](CONTRIBUTING.md) for the complete development and pull-request guide.
578
+
579
+ ~~~ bash
350
580
  git clone https://github.com/LukasNiessen/ArchUnitRuby.git
351
581
  cd ArchUnitRuby
352
582
  bundle install
353
583
  bundle exec rake
354
584
  bundle exec rake docs
355
585
  gem build archunit.gemspec --strict
356
- ```
586
+ ~~~
357
587
 
358
- `bundle exec rake` runs the randomized RSpec suite and RuboCop. CI additionally enforces 98% line
588
+ `bundle exec rake` validates release metadata, then runs the randomized RSpec suite and RuboCop.
589
+ CI additionally enforces 98% line
359
590
  and 90% branch coverage, runs the dogfooding rules explicitly, builds the documentation, loads the
360
591
  public API with Ruby warnings, tests optional RSpec/Minitest integrations, builds and installs the
361
592
  gem artifact, runs the external RAG fixture, and checks Ruby 3.3, 3.4, and 4.0 across Ubuntu and
362
593
  Windows.
363
594
 
595
+ Cold and warm extraction can be profiled independently with a generated multi-gem corpus. The
596
+ benchmark reports stage timings, resolution-cache effectiveness, Ruby heap growth, and peak RSS on
597
+ platforms that expose it:
598
+
599
+ ~~~ bash
600
+ bundle exec ruby benchmark/extraction.rb
601
+ ~~~
602
+
603
+ See the [benchmark guide](https://github.com/LukasNiessen/ArchUnitRuby/blob/main/benchmark/README.md)
604
+ for corpus controls, JSON output, and CI limits.
605
+
606
+ The separate [real-world canary](https://github.com/LukasNiessen/ArchUnitRuby/blob/main/benchmark/canary/README.md)
607
+ checks pinned Rack, Rake, and RSpec production-library sources in both dependency modes, including
608
+ passing and deliberately failing rules, graph baselines, and cold/warm performance. It never runs
609
+ those projects or installs their dependencies, and is not scheduled automatically yet.
610
+
364
611
  The implementation conventions and intended dependency directions live in [`AGENTS.md`](AGENTS.md).
365
612
 
366
- ## Current limitations
613
+ ## 1.0 compatibility and migration
614
+
615
+ The documented entry points, fluent rule methods, options, public data/error types, and report
616
+ formats in the [API guide](https://lukasniessen.github.io/ArchUnitRuby/file.API.html) form the 1.x
617
+ compatibility baseline. Additive capabilities may be introduced in minor versions; incompatible
618
+ public API changes require a new major version and migration notes. Private implementation helpers
619
+ are not part of that contract. Correctness fixes can reveal violations that earlier versions missed;
620
+ review the changelog when updating.
621
+
622
+ When upgrading from 0.0.1:
367
623
 
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.
624
+ - The default remains import-only. Explicitly opt into `dependency_analysis: :imports_and_constants`
625
+ when you want the documented static constant-reference model; it may reveal additional violations.
626
+ - Included malformed Ruby now raises `SourceParseError < TechnicalError`, rather than silently
627
+ passing or returning zero AST counts. Fix or explicitly exclude invalid source files.
628
+ - Project `.archignore` exclusions and skipped symlinked source entries can change the discovered
629
+ file set. Empty selections are still violations by default; check selectors rather than blindly
630
+ allowing empty tests.
631
+ - Cached graph results still require explicit clearing after source edits in a long-lived process.
632
+
633
+ Stable API compatibility does not mean complete Ruby interpretation or exhaustive runtime detection.
634
+ The supported static-analysis boundaries below remain part of the contract.
635
+
636
+ ## 📅 Plans and Current Limitations
637
+
638
+ - Files are the primary vocabulary; opt-in constant analysis has the lookup limits described above.
639
+ - Runtime-derived import paths cannot be resolved; safely known local literal strings are supported.
370
640
  - 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.
641
+ - `.archignore` supports exclusion globs, not negated gitignore patterns or nested ignore files.
642
+ - Cycle traversal is iterative and tested on 10,000-node chains and rings. Enumerating every cycle
643
+ can still be expensive in densely connected graphs; narrow the selected scope when necessary.
644
+ - Method-call/protocol rules, a standalone CLI, presets, and adoption baselines are roadmap work,
645
+ not claims made by the current 1.0 API.
646
+
647
+ ## 💟 Community
648
+
649
+ ### Maintainers
650
+
651
+ - **[Tristan Kruse](https://github.com/TristanKruse)** — Tech Lead & Maintainer
652
+ - **[Lukas Niessen](https://github.com/LukasNiessen)** — Maintainer
653
+
654
+ ### Contributors
372
655
 
373
- ## License
656
+ See everyone who has contributed on the [GitHub contributors page](https://github.com/LukasNiessen/ArchUnitRuby/graphs/contributors).
657
+
658
+ Questions and feature ideas are welcome in [GitHub Issues](https://github.com/LukasNiessen/ArchUnitRuby/issues).
659
+ For usage help, responsible vulnerability reporting, and community expectations, see
660
+ [SUPPORT.md](SUPPORT.md), [SECURITY.md](SECURITY.md), and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
661
+
662
+ ### Star History
663
+
664
+ [![Star History Chart](https://api.star-history.com/svg?repos=LukasNiessen/ArchUnitRuby&type=Date)](https://star-history.com/#LukasNiessen/ArchUnitRuby&Date)
665
+
666
+ ## 📄 License
374
667
 
375
668
  [MIT](LICENSE)
669
+
670
+ <div align="right">
671
+
672
+ [Go back to the top](#top)
673
+
674
+ </div>