archspec_diagram 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +127 -0
- data/exe/archspec_diagram +3 -0
- data/lib/archspec_diagram/architecture.rb +165 -0
- data/lib/archspec_diagram/clauses.rb +101 -0
- data/lib/archspec_diagram/cli.rb +56 -0
- data/lib/archspec_diagram/document.rb +69 -0
- data/lib/archspec_diagram/flowchart.rb +154 -0
- data/lib/archspec_diagram/version.rb +3 -0
- data/lib/archspec_diagram.rb +57 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 20f4bfd8162c8a22ed9b45f78d9034fc5723413d47eb4010b6918d97322aa44e
|
|
4
|
+
data.tar.gz: 171d64479ccb7cef9940e992cc43cbd5d1fd58728ed733eaaac77fde1eba8dda
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 21c5f484f1f9abcfe3b63ad5092515163df873b311c7e86e4bce3ba6390316c5c9cf907200acffe71a6fc5ea58428c8634e385387b0a23b32638ed0819bae3df
|
|
7
|
+
data.tar.gz: a5762a84fe743b4858df0e7bc6b2daed24123e2861e30068f577c727f3d2b45343eabe9015779ae8f3739c687ce812da4e25a29bc648630486d72717e3203159
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 meganemura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# archspec_diagram
|
|
2
|
+
|
|
3
|
+
Draw an [Archspec](https://archspecrb.dev) `Archspec.rb` as a Mermaid flowchart: components as boxes, allowed and forbidden dependencies as arrows, the other rules as text inside the boxes. It reads the config the way `archspec check` does but never analyzes your code, so it is fast and needs no Rails boot.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
gem install archspec_diagram
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Ruby 3.2 or later and archspec 1.1.x.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
archspec_diagram # ./Archspec.rb -> Mermaid on stdout
|
|
15
|
+
archspec_diagram path/to/app # a directory works; uses its Archspec.rb
|
|
16
|
+
archspec_diagram -f markdown -o ARCHITECTURE.md
|
|
17
|
+
archspec_diagram -f html -o arch.html && open arch.html
|
|
18
|
+
archspec_diagram --focus billing -d LR # one component and its neighbours
|
|
19
|
+
archspec_diagram --detail reasons # names | rules (default) | reasons
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Errors are one line on stderr, never a backtrace. Exit status is 0 on success, 1 for a config or I/O problem (`archspec_diagram: could not load Archspec.rb: undefined local variable or method 'modles'`), and 2 for a usage error (`archspec_diagram: unknown format: svg (expected mermaid, markdown or html)`).
|
|
23
|
+
|
|
24
|
+
### Options
|
|
25
|
+
|
|
26
|
+
| Option | Values | Default | Effect |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| `-f`, `--format` | `mermaid`, `markdown`, `html` | `mermaid` | `mermaid` is the bare diagram for pasting into GitHub, Notion or `mmdc`. `markdown` is a heading, a fenced block and a legend table. `html` is a standalone page that loads Mermaid 11.4.1 from jsdelivr. |
|
|
29
|
+
| `-d`, `--direction` | `TB`, `BT`, `LR`, `RL` | `TB` | Layout direction. |
|
|
30
|
+
| `--detail` | `names`, `rules`, `reasons` | `rules` | `names`: components and arrows. `rules`: plus selectors and rule lines. `reasons`: plus every `because:` text. |
|
|
31
|
+
| `--focus` | a component name | none | Keep one component, every component it links to or from, and the project-wide rules. |
|
|
32
|
+
| `-o`, `--output` | a file | stdout | Written to a temporary file and renamed, so a failed run leaves the previous diagram in place. |
|
|
33
|
+
| `-v`, `--version`, `-h`, `--help` | | | |
|
|
34
|
+
|
|
35
|
+
### From Ruby
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
ArchspecDiagram.render("Archspec.rb", format: :markdown, direction: :LR, detail: :rules, focus: :billing)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`render` returns a String and raises `ArchspecDiagram::Error` (a subclass of `ArchSpec::Error`) with a one-line message for every user-caused failure: bad options, a missing, unreadable, broken or empty config, an unknown `focus`. A test that fails when a committed diagram is stale:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
assert_equal File.read("docs/architecture.mmd"), ArchspecDiagram.render("Archspec.rb")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Rendering is deterministic and follows declaration order, so an unchanged config produces an unchanged diagram.
|
|
48
|
+
|
|
49
|
+
## What is drawn
|
|
50
|
+
|
|
51
|
+
| In Archspec.rb | In the diagram |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `component :x, in:, except:, namespace:, constants:, descendants_of:` | A box named `x` with its selectors as small text. Flat, never nested, because selectors overlap. |
|
|
54
|
+
| `architecture :name` / `preset :name` | A labelled group around the components the call declared, plus a "project-wide" note for its project-wide rules. Two identical calls stay two groups. |
|
|
55
|
+
| `can_only_use` | Green arrows to each allowed component and a "uses only: ..." line (or "uses no other component"). |
|
|
56
|
+
| `cannot_use` | Red dashed arrows ending in a cross, labelled "cannot use". |
|
|
57
|
+
| `can_only_be_used_by` | Thick blue arrows from each consumer, labelled "only consumer", and a "used only by: ..." line. When the same pair also has a `can_only_use` arrow, the two merge into one green arrow labelled "only consumer". |
|
|
58
|
+
| `cannot_reference_constants` | A red hexagon per constant and a dashed red arrow to it, labelled "cannot reference". |
|
|
59
|
+
| `must_be_empty` | A dashed, faded box with "must be empty". |
|
|
60
|
+
| `no_cycles`, `method_names.matching(...)` without a component | A dashed "project rules" note (top level) or "project-wide" note (inside a preset group). |
|
|
61
|
+
| `cannot_call`, `cannot_define`, `cannot_instantiate_and_invoke`, `cannot_reference_includers`, `must_implement`, `must_implement_one_of`, `public_api`, `method_names.matching(...)` on a component | One line of text in the component's box. |
|
|
62
|
+
| `because:` | At `--detail reasons`, an italic line under the rule's text, or on the arrow for rules that only draw arrows. |
|
|
63
|
+
| A component a rule names but nothing declares | A dashed box. |
|
|
64
|
+
| Any rule object archspec_diagram does not know (`rule obj`) | A generic "rule <id>" line. |
|
|
65
|
+
| `root`, `source`, `ignore`, `todo` | Not drawn. |
|
|
66
|
+
|
|
67
|
+
The text of each rule lives in `lib/archspec_diagram/clauses.rb`, one decoder per archspec rule class; `test/rule_coverage_test.rb` fails when archspec ships a rule class without one. All Mermaid syntax (ids, escaping, arrows, styles) lives in `flowchart.rb`.
|
|
68
|
+
|
|
69
|
+
## Example
|
|
70
|
+
|
|
71
|
+
Rendered from `test/fixtures/kitchen_sink/Archspec.rb`, which uses every verb (`archspec_diagram test/fixtures/kitchen_sink`):
|
|
72
|
+
|
|
73
|
+
```mermaid
|
|
74
|
+
flowchart TB
|
|
75
|
+
subgraph g0["architecture :modular_monolith, components: {…}, allow: {…}, public: {…}"]
|
|
76
|
+
c0["<b>billing</b><br/><small>in: packs/billing/**/*.rb</small><br/>uses only: shared<br/>public API: packs/billing/app/public/**/*.rb; constants Billing::Api; namespace Billing::Public"]
|
|
77
|
+
c1["<b>catalog</b><br/><small>in: packs/catalog/**/*.rb</small><br/>uses only: shared"]
|
|
78
|
+
c2["<b>shared</b><br/><small>in: packs/shared/**/*.rb</small><br/>uses no other component<br/>used only by: billing, catalog, services"]
|
|
79
|
+
n_g0["<b>project-wide</b><br/>no cycles among: billing, catalog, shared"]:::note
|
|
80
|
+
end
|
|
81
|
+
c3["<b>my-engine</b><br/><small>in: engines/my-engine/**/*.rb</small>"]
|
|
82
|
+
c4["<b>controllers</b><br/><small>in: app/controllers/**/*.rb</small><br/>uses only: models, services"]
|
|
83
|
+
c5["<b>models</b><br/><small>in: app/models/**/*.rb</small><br/><small>except: app/models/legacy/**/*.rb</small><br/>cannot call find_by_sql on ActiveRecord::Base or its subclasses<br/>instance methods matching /\Awith_(?#lt;base#gt;.+)/: require without_%{base}<br/>class methods matching /\Aget_/: forbidden, except get_started"]
|
|
84
|
+
c6["<b>services</b><br/><small>in: app/services/**/*.rb</small><br/>cannot call render, redirect_to, params, session<br/>cannot call puts (bare calls only)<br/>cannot do Thing.new(...).call"]
|
|
85
|
+
c7["<b>records</b><br/><small>descendants of: ApplicationRecord</small><br/>cannot define call"]
|
|
86
|
+
c8["<b>commands</b><br/><small>namespace: Commands</small><br/>must implement #35;call (arity 1, keywords: actor)<br/>must implement one of .perform, .call"]
|
|
87
|
+
c9["<b>model_concerns</b><br/><small>in: app/models/concerns/**/*.rb</small><br/>cannot reference its includers"]
|
|
88
|
+
c10["<b>end</b><br/><small>in: app/end/**/*.rb</small><br/>must be empty"]:::empty
|
|
89
|
+
c11["<b>legacy</b><br/><small>constants: OldReport, OldExport</small><br/>instance methods matching /\Aold_(?#lt;x#gt;.+)/: require new_%{x} on models (class side)"]
|
|
90
|
+
c12["<b>ghost</b>"]:::undeclared
|
|
91
|
+
n_top["<b>project rules</b><br/>no cycles among: controllers, models, services"]:::note
|
|
92
|
+
subgraph g1["preset :ruby_conventions"]
|
|
93
|
+
n_g1["<b>project-wide</b><br/>instance methods matching /\A(get|set)_/: forbidden<br/>instance methods matching /\Ais_/: forbidden<br/>class methods matching /\A(get|set)_/: forbidden<br/>class methods matching /\Ais_/: forbidden"]:::note
|
|
94
|
+
end
|
|
95
|
+
k0{{"ActionController"}}:::constant
|
|
96
|
+
k1{{"ActionView"}}:::constant
|
|
97
|
+
c0 -->|"only consumer"| c2
|
|
98
|
+
c1 -->|"only consumer"| c2
|
|
99
|
+
c4 --> c5
|
|
100
|
+
c4 --> c6
|
|
101
|
+
c5 -.-x|"cannot use"| c4
|
|
102
|
+
c5 -.-x|"cannot reference"| k0
|
|
103
|
+
c5 -.-x|"cannot reference"| k1
|
|
104
|
+
c6 ==>|"only consumer"| c2
|
|
105
|
+
c11 -.-x|"cannot use"| c12
|
|
106
|
+
linkStyle 0,1,2,3 stroke:#2f9e44,stroke-width:2px
|
|
107
|
+
linkStyle 4,5,6,8 stroke:#e03131,stroke-width:1.5px,stroke-dasharray:5 4
|
|
108
|
+
linkStyle 7 stroke:#1971c2,stroke-width:2px
|
|
109
|
+
classDef note fill:none,stroke-dasharray:2 2
|
|
110
|
+
classDef empty stroke-dasharray:4 3,opacity:0.6
|
|
111
|
+
classDef undeclared stroke-dasharray:2 2,fill:none
|
|
112
|
+
classDef constant fill:none,stroke:#e03131,color:#e03131
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
ruby -Itest -e 'Dir["test/*_test.rb"].each { load _1 }'
|
|
119
|
+
MMDC=path/to/mmdc ruby -Itest test/render_test.rb # also checks the golden diagrams parse
|
|
120
|
+
UPDATE_GOLDEN=1 ruby -Itest test/render_test.rb # rewrite test/golden after an intended change
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The golden diagrams in `test/golden` come from the Archspec.rb files of archspec itself, fizzy, mastodon, discourse and the rails_app fixture (vendored under `test/fixtures/upstream`), plus the kitchen sink.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module ArchspecDiagram
|
|
2
|
+
class Architecture < Data.define(:title, :components, :clauses)
|
|
3
|
+
Origin = Data.define(:seq, :verb, :preset, :options) do
|
|
4
|
+
def label
|
|
5
|
+
args = options.map { |key, value| "#{key}: #{brief(value)}" }
|
|
6
|
+
["#{verb} :#{preset}", *args].join(", ")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def brief(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash then "{…}"
|
|
14
|
+
when Array then "[…]"
|
|
15
|
+
when String, Symbol then value.inspect
|
|
16
|
+
else value.to_s
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Component = Data.define(:name, :selectors, :origin, :declared)
|
|
22
|
+
|
|
23
|
+
Constant = Data.define(:name)
|
|
24
|
+
|
|
25
|
+
Link = Data.define(:from, :to, :kind)
|
|
26
|
+
|
|
27
|
+
Clause = Data.define(:subject, :summary, :links, :reason, :origin, :empty) do
|
|
28
|
+
def project_wide? = subject.nil?
|
|
29
|
+
def drawn_as_links_only? = summary.nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.load(path)
|
|
33
|
+
file = resolve(path)
|
|
34
|
+
source = read(file, path)
|
|
35
|
+
ledger = Ledger.new
|
|
36
|
+
definition = ArchSpec::Definition.new
|
|
37
|
+
definition.base_dir = File.dirname(file)
|
|
38
|
+
definition.extend(ArchSpec::DSL::Context)
|
|
39
|
+
definition.extend(ledger.tracking)
|
|
40
|
+
evaluate(definition, source, file, path)
|
|
41
|
+
|
|
42
|
+
clauses = definition.rules.map { Clauses.decode(_1, origin: ledger.rule_origin(_1)) }
|
|
43
|
+
components = definition.component_specs.to_h do |name, spec|
|
|
44
|
+
[name, Component.new(name:, selectors: selector_lines(spec), origin: ledger.component_origin(name), declared: true)]
|
|
45
|
+
end
|
|
46
|
+
add_undeclared(components, clauses)
|
|
47
|
+
new(title: File.basename(File.dirname(File.expand_path(file))), components:, clauses:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def focus(name)
|
|
51
|
+
name = name.to_sym
|
|
52
|
+
unless components.key?(name)
|
|
53
|
+
raise Error, "unknown component #{name.inspect} (known: #{components.keys.join(', ')})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
links = clauses.flat_map(&:links)
|
|
57
|
+
keep = [name, *links.filter_map { _1.to if _1.from == name }, *links.filter_map { _1.from if _1.to == name }]
|
|
58
|
+
.grep(Symbol).uniq
|
|
59
|
+
kept_clauses = clauses.filter_map do |clause|
|
|
60
|
+
next clause if clause.project_wide?
|
|
61
|
+
next unless keep.include?(clause.subject)
|
|
62
|
+
|
|
63
|
+
inside = clause.links.select { keep.include?(_1.from) && (_1.to.is_a?(Constant) || keep.include?(_1.to)) }
|
|
64
|
+
next if inside.empty? && clause.drawn_as_links_only? && !clause.links.empty?
|
|
65
|
+
|
|
66
|
+
clause.with(links: inside)
|
|
67
|
+
end
|
|
68
|
+
with(components: components.slice(*keep), clauses: kept_clauses)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.resolve(path)
|
|
72
|
+
path = path.to_s
|
|
73
|
+
file = File.directory?(path) ? File.join(path, "Archspec.rb") : path
|
|
74
|
+
unless File.file?(file)
|
|
75
|
+
raise Error, "could not load #{path}: #{File.directory?(path) ? 'no Archspec.rb in that directory' : 'no such file'}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
file
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.read(file, path)
|
|
82
|
+
File.read(file, encoding: "UTF-8")
|
|
83
|
+
rescue SystemCallError => e
|
|
84
|
+
raise Error, "could not load #{path}: #{SystemCallError.new(e.errno).message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.evaluate(definition, source, file, path)
|
|
88
|
+
definition.instance_eval(source, file)
|
|
89
|
+
return unless definition.component_specs.empty? && definition.rules.empty?
|
|
90
|
+
|
|
91
|
+
raise Error, "#{path} declares no components or rules"
|
|
92
|
+
rescue Error
|
|
93
|
+
raise
|
|
94
|
+
rescue ScriptError, StandardError => e
|
|
95
|
+
raise Error, "could not load #{path}: #{e.message.lines.map(&:strip).find { !_1.empty? } || e.class.name}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.add_undeclared(components, clauses)
|
|
99
|
+
clauses.each do |clause|
|
|
100
|
+
names = [clause.subject, *clause.links.flat_map { [_1.from, _1.to] }].grep(Symbol)
|
|
101
|
+
names.each do |name|
|
|
102
|
+
components[name] ||= Component.new(name:, selectors: [], origin: clause.origin, declared: false)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.selector_lines(spec)
|
|
108
|
+
[
|
|
109
|
+
["in", spec.file_patterns],
|
|
110
|
+
["except", spec.exclude_patterns],
|
|
111
|
+
["namespace", spec.namespaces],
|
|
112
|
+
["constants", spec.constants],
|
|
113
|
+
["descendants of", spec.ancestor_names]
|
|
114
|
+
].reject { |_, values| values.empty? }.map { |label, values| "#{label}: #{values.join(', ')}" }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private_class_method :resolve, :read, :evaluate, :add_undeclared, :selector_lines
|
|
118
|
+
|
|
119
|
+
class Ledger
|
|
120
|
+
def initialize
|
|
121
|
+
@current = nil
|
|
122
|
+
@seq = 0
|
|
123
|
+
@rule_origins = {}.compare_by_identity
|
|
124
|
+
@component_origins = {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Both entry points need wrapping: Context aliases preset to the
|
|
128
|
+
# original architecture body, so overriding architecture alone misses
|
|
129
|
+
# `preset :ruby_conventions`.
|
|
130
|
+
def tracking
|
|
131
|
+
ledger = self
|
|
132
|
+
Module.new do
|
|
133
|
+
define_method(:architecture) do |name, **options|
|
|
134
|
+
ledger.within(:architecture, name, options) { super(name, **options) }
|
|
135
|
+
end
|
|
136
|
+
define_method(:preset) do |name, **options|
|
|
137
|
+
ledger.within(:preset, name, options) { super(name, **options) }
|
|
138
|
+
end
|
|
139
|
+
define_method(:add_rule) do |rule|
|
|
140
|
+
ledger.claim_rule(rule)
|
|
141
|
+
super(rule)
|
|
142
|
+
end
|
|
143
|
+
define_method(:add_component) do |spec|
|
|
144
|
+
ledger.claim_component(spec.name)
|
|
145
|
+
super(spec)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def within(verb, name, options)
|
|
151
|
+
outer = @current
|
|
152
|
+
@current = Origin.new(seq: @seq += 1, verb:, preset: name.to_sym, options:)
|
|
153
|
+
yield
|
|
154
|
+
ensure
|
|
155
|
+
@current = outer
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def claim_rule(rule) = @rule_origins[rule] ||= @current
|
|
159
|
+
def claim_component(name) = @component_origins.key?(name) || (@component_origins[name] = @current)
|
|
160
|
+
def rule_origin(rule) = @rule_origins[rule]
|
|
161
|
+
def component_origin(name) = @component_origins[name]
|
|
162
|
+
end
|
|
163
|
+
private_constant :Ledger
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module ArchspecDiagram
|
|
2
|
+
module Clauses
|
|
3
|
+
Link = Architecture::Link
|
|
4
|
+
Constant = Architecture::Constant
|
|
5
|
+
|
|
6
|
+
DECODERS = {
|
|
7
|
+
ArchSpec::Rules::AllowDependenciesRule => lambda do |rule|
|
|
8
|
+
{
|
|
9
|
+
subject: rule.source,
|
|
10
|
+
summary: rule.targets.empty? ? "uses no other component" : "uses only: #{list(rule.targets)}",
|
|
11
|
+
links: rule.targets.map { Link.new(from: rule.source, to: _1, kind: :allow) }
|
|
12
|
+
}
|
|
13
|
+
end,
|
|
14
|
+
ArchSpec::Rules::ForbidDependenciesRule => lambda do |rule|
|
|
15
|
+
{ subject: rule.source, links: rule.targets.map { Link.new(from: rule.source, to: _1, kind: :forbid) } }
|
|
16
|
+
end,
|
|
17
|
+
ArchSpec::Rules::AllowedConsumersRule => lambda do |rule|
|
|
18
|
+
{
|
|
19
|
+
subject: rule.source,
|
|
20
|
+
summary: rule.consumers.empty? ? "used by no other component" : "used only by: #{list(rule.consumers)}",
|
|
21
|
+
links: rule.consumers.map { Link.new(from: _1, to: rule.source, kind: :consumer) }
|
|
22
|
+
}
|
|
23
|
+
end,
|
|
24
|
+
ArchSpec::Rules::CannotReferenceConstantsRule => lambda do |rule|
|
|
25
|
+
{
|
|
26
|
+
subject: rule.source,
|
|
27
|
+
links: rule.constants.map { Link.new(from: rule.source, to: Constant.new(_1), kind: :forbid_constant) }
|
|
28
|
+
}
|
|
29
|
+
end,
|
|
30
|
+
ArchSpec::Rules::NoCyclesRule => lambda do |rule|
|
|
31
|
+
{ subject: nil, summary: rule.components.empty? ? "no cycles among all components" : "no cycles among: #{list(rule.components)}" }
|
|
32
|
+
end,
|
|
33
|
+
ArchSpec::Rules::MustBeEmptyRule => ->(rule) { { subject: rule.source, summary: "must be empty", empty: true } },
|
|
34
|
+
ArchSpec::Rules::ConcernIndependenceRule => ->(rule) { { subject: rule.source, summary: "cannot reference its includers" } },
|
|
35
|
+
ArchSpec::Rules::PublicApiRule => lambda do |rule|
|
|
36
|
+
parts = [
|
|
37
|
+
(list(rule.file_patterns) unless rule.file_patterns.empty?),
|
|
38
|
+
("constants #{list(rule.constants)}" unless rule.constants.empty?),
|
|
39
|
+
("namespace #{list(rule.namespaces)}" unless rule.namespaces.empty?)
|
|
40
|
+
].compact
|
|
41
|
+
{ subject: rule.source, summary: ["public API", parts.join("; ")].reject(&:empty?).join(": ") }
|
|
42
|
+
end,
|
|
43
|
+
ArchSpec::Rules::CannotCallRule => lambda do |rule|
|
|
44
|
+
receiver =
|
|
45
|
+
case rule.receiver
|
|
46
|
+
when :any then ""
|
|
47
|
+
when :none then " (bare calls only)"
|
|
48
|
+
else " on #{rule.receiver} or its subclasses"
|
|
49
|
+
end
|
|
50
|
+
{ subject: rule.source, summary: "cannot call #{list(rule.method_names)}#{receiver}" }
|
|
51
|
+
end,
|
|
52
|
+
ArchSpec::Rules::MustImplementRule => lambda do |rule|
|
|
53
|
+
extras = []
|
|
54
|
+
extras << "arity #{rule.arity}" if rule.arity
|
|
55
|
+
extras << "keywords: #{list(rule.keywords)}" unless rule.keywords.empty?
|
|
56
|
+
summary = "must implement #{sigil(rule.scope)}#{rule.method_name}"
|
|
57
|
+
summary += " (#{extras.join(', ')})" unless extras.empty?
|
|
58
|
+
{ subject: rule.source, summary: }
|
|
59
|
+
end,
|
|
60
|
+
ArchSpec::Rules::MustImplementOneOfRule => lambda do |rule|
|
|
61
|
+
names = rule.method_names.map { "#{sigil(rule.scope)}#{_1}" }
|
|
62
|
+
{ subject: rule.source, summary: "must implement one of #{list(names)}" }
|
|
63
|
+
end,
|
|
64
|
+
ArchSpec::Rules::CannotDefineMethodRule => ->(rule) { { subject: rule.source, summary: "cannot define #{list(rule.method_names)}" } },
|
|
65
|
+
ArchSpec::Rules::CannotInstantiateAndInvokeRule => ->(rule) { { subject: rule.source, summary: "cannot do Thing.new(...).call" } },
|
|
66
|
+
ArchSpec::Rules::NamingRule => lambda do |rule|
|
|
67
|
+
constraint = rule.instance_variable_get(:@constraint)
|
|
68
|
+
phrase =
|
|
69
|
+
case constraint
|
|
70
|
+
when ArchSpec::Rules::Naming::Forbidden then "forbidden"
|
|
71
|
+
when ArchSpec::Rules::Naming::Requires
|
|
72
|
+
on = constraint.instance_variable_get(:@on)
|
|
73
|
+
side = constraint.instance_variable_get(:@target_scope) == :class ? " (class side)" : ""
|
|
74
|
+
"require #{constraint.instance_variable_get(:@template)}#{" on #{on}" if on}#{side}"
|
|
75
|
+
else constraint.respond_to?(:id) ? constraint.id : "naming rule"
|
|
76
|
+
end
|
|
77
|
+
except = rule.except.empty? ? "" : ", except #{list(rule.except)}"
|
|
78
|
+
{
|
|
79
|
+
subject: rule.source,
|
|
80
|
+
summary: "#{rule.scope} methods matching #{rule.selector.regex.inspect}: #{phrase}#{except}"
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
def self.decode(rule, origin:)
|
|
86
|
+
attrs = DECODERS.fetch(rule.class) { method(:custom) }.call(rule)
|
|
87
|
+
reason = rule.respond_to?(:archspec_because) ? rule.archspec_because : nil
|
|
88
|
+
Architecture::Clause.new(subject: nil, summary: nil, links: [], empty: false, **attrs, reason:, origin:)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.custom(rule)
|
|
92
|
+
{
|
|
93
|
+
subject: (rule.source.to_sym if rule.respond_to?(:source) && rule.source),
|
|
94
|
+
summary: "rule #{rule.respond_to?(:id) ? rule.id : rule.class.name}"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.list(names) = names.to_a.join(", ")
|
|
99
|
+
def self.sigil(scope) = scope == :class ? "." : "#"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module ArchspecDiagram
|
|
4
|
+
# usage: archspec_diagram [options] [PATH]
|
|
5
|
+
# PATH is an Archspec.rb or a directory holding one (default ./Archspec.rb).
|
|
6
|
+
# Exit 0 on success, 1 for a config or I/O problem, 2 for a usage error.
|
|
7
|
+
# Every failure prints one line to stderr and never a backtrace.
|
|
8
|
+
module CLI
|
|
9
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
10
|
+
options = {}
|
|
11
|
+
output = nil
|
|
12
|
+
parser = OptionParser.new do |o|
|
|
13
|
+
o.banner = "usage: archspec_diagram [options] [PATH]"
|
|
14
|
+
o.on("-f", "--format FORMAT", "mermaid (default), markdown, html") { options[:format] = _1 }
|
|
15
|
+
o.on("-d", "--direction DIR", "TB (default), BT, LR, RL") { options[:direction] = _1 }
|
|
16
|
+
o.on("--detail LEVEL", "names, rules (default), reasons") { options[:detail] = _1 }
|
|
17
|
+
o.on("--focus COMPONENT", "one component and its neighbours") { options[:focus] = _1 }
|
|
18
|
+
o.on("-o", "--output FILE", "write here instead of stdout") { output = _1 }
|
|
19
|
+
o.on("-v", "--version") { return out.puts(VERSION).then { 0 } }
|
|
20
|
+
o.on("-h", "--help") { return out.puts(o).then { 0 } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
paths = parser.parse(argv)
|
|
24
|
+
return usage_error(err, "expected one PATH") if paths.size > 1
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
Options.new(**options)
|
|
28
|
+
rescue Error => e
|
|
29
|
+
return usage_error(err, e.message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = ArchspecDiagram.render(paths.first || "Archspec.rb", **options)
|
|
33
|
+
output ? write_atomically(output, result) : out.write(result)
|
|
34
|
+
0
|
|
35
|
+
rescue OptionParser::ParseError => e
|
|
36
|
+
usage_error(err, e.message)
|
|
37
|
+
rescue Error, SystemCallError => e
|
|
38
|
+
err.puts "archspec_diagram: #{e.message}"
|
|
39
|
+
1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.usage_error(err, message)
|
|
43
|
+
err.puts "archspec_diagram: #{message}"
|
|
44
|
+
2
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.write_atomically(path, content)
|
|
48
|
+
temp = "#{path}.tmp-#{Process.pid}"
|
|
49
|
+
File.write(temp, content)
|
|
50
|
+
File.rename(temp, path)
|
|
51
|
+
ensure
|
|
52
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
53
|
+
end
|
|
54
|
+
private_class_method :usage_error, :write_atomically
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module ArchspecDiagram
|
|
4
|
+
class Document
|
|
5
|
+
MERMAID_JS = "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs"
|
|
6
|
+
|
|
7
|
+
def initialize(flowchart, options, title:)
|
|
8
|
+
@flowchart = flowchart
|
|
9
|
+
@options = options
|
|
10
|
+
@title = title
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
case @options.format
|
|
15
|
+
when :mermaid then "#{diagram}\n"
|
|
16
|
+
when :markdown then markdown
|
|
17
|
+
when :html then html
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def diagram = @flowchart.to_s
|
|
24
|
+
|
|
25
|
+
def markdown
|
|
26
|
+
fence = "`" * [3, longest_backtick_run + 1].max
|
|
27
|
+
lines = ["## #{@title.gsub(/\R/, ' ')} architecture", "", "#{fence}mermaid", diagram, fence]
|
|
28
|
+
unless legend.empty?
|
|
29
|
+
lines.push("", "| Arrow | Meaning |", "| --- | --- |")
|
|
30
|
+
lines.concat(legend.map { |look, meaning| "| #{look} | #{meaning} |" })
|
|
31
|
+
end
|
|
32
|
+
"#{lines.join("\n")}\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Mermaid reads the element's textContent, so escaped tags come back as tags.
|
|
36
|
+
def html
|
|
37
|
+
items = legend.map { |look, meaning| " <li><strong>#{CGI.escapeHTML(look)}</strong>: #{CGI.escapeHTML(meaning)}</li>" }
|
|
38
|
+
<<~HTML
|
|
39
|
+
<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
<title>#{CGI.escapeHTML(@title)} architecture</title>
|
|
45
|
+
<style>
|
|
46
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; }
|
|
47
|
+
ul { line-height: 1.6; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<h1>#{CGI.escapeHTML(@title)} architecture</h1>
|
|
52
|
+
<pre class="mermaid">
|
|
53
|
+
#{CGI.escapeHTML(diagram)}
|
|
54
|
+
</pre>
|
|
55
|
+
#{items.empty? ? '' : " <ul>\n#{items.join("\n")}\n </ul>\n"} <script type="module">
|
|
56
|
+
import mermaid from "#{MERMAID_JS}";
|
|
57
|
+
mermaid.initialize({ startOnLoad: false });
|
|
58
|
+
await mermaid.run();
|
|
59
|
+
</script>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
HTML
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def legend = @legend ||= @flowchart.legend
|
|
66
|
+
|
|
67
|
+
def longest_backtick_run = diagram.scan(/`+/).map(&:length).max || 0
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module ArchspecDiagram
|
|
4
|
+
class Flowchart
|
|
5
|
+
LINKS = {
|
|
6
|
+
allow: { arrow: "-->", label: nil, style: "stroke:#2f9e44,stroke-width:2px",
|
|
7
|
+
look: "green arrow",
|
|
8
|
+
legend: "allowlist: the source may use only components it points to" },
|
|
9
|
+
consumer: { arrow: "==>", label: "only consumer", style: "stroke:#1971c2,stroke-width:2px",
|
|
10
|
+
look: "thick blue arrow",
|
|
11
|
+
legend: "audience: only the sources pointing here may use the target" },
|
|
12
|
+
forbid: { arrow: "-.-x", label: "cannot use", style: "stroke:#e03131,stroke-width:1.5px,stroke-dasharray:5 4",
|
|
13
|
+
look: "dashed red arrow ending in a cross",
|
|
14
|
+
legend: "the source must not use the target" },
|
|
15
|
+
forbid_constant: { arrow: "-.-x", label: "cannot reference", style: "stroke:#e03131,stroke-width:1.5px,stroke-dasharray:5 4",
|
|
16
|
+
look: "dashed red arrow to a red hexagon",
|
|
17
|
+
legend: "the source must not reference the constant" }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
CLASS_DEFS = {
|
|
21
|
+
empty: "stroke-dasharray:4 3,opacity:0.6",
|
|
22
|
+
undeclared: "stroke-dasharray:2 2,fill:none",
|
|
23
|
+
constant: "fill:none,stroke:#e03131,color:#e03131",
|
|
24
|
+
note: "fill:none,stroke-dasharray:2 2"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
Edge = Data.define(:from, :to, :kind, :label)
|
|
28
|
+
Constant = Architecture::Constant
|
|
29
|
+
private_constant :Edge, :Constant
|
|
30
|
+
|
|
31
|
+
def initialize(architecture, options)
|
|
32
|
+
@architecture = architecture
|
|
33
|
+
@options = options
|
|
34
|
+
@component_ids = architecture.components.keys.each_with_index.to_h { |name, index| [name, "c#{index}"] }
|
|
35
|
+
constants = architecture.clauses.flat_map(&:links).map(&:to).grep(Constant).uniq
|
|
36
|
+
@constant_ids = constants.each_with_index.to_h { |constant, index| [constant, "k#{index}"] }
|
|
37
|
+
@classes = Set.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_s
|
|
41
|
+
@to_s ||= begin
|
|
42
|
+
edges = merged_edges
|
|
43
|
+
lines = ["flowchart #{@options.direction}"]
|
|
44
|
+
lines.concat(group_lines)
|
|
45
|
+
@classes << :constant unless @constant_ids.empty?
|
|
46
|
+
lines.concat(@constant_ids.map { |constant, id| " #{id}{{\"#{text(constant.name)}\"}}:::constant" })
|
|
47
|
+
lines.concat(edges.map { edge_line(_1) })
|
|
48
|
+
lines.concat(link_style_lines(edges))
|
|
49
|
+
lines.concat(class_def_lines)
|
|
50
|
+
lines.join("\n")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def legend
|
|
55
|
+
present = @architecture.clauses.flat_map(&:links).map(&:kind).uniq
|
|
56
|
+
LINKS.slice(*present).values.map { [_1[:look], _1[:legend]] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def group_lines
|
|
62
|
+
buckets = Hash.new { |hash, origin| hash[origin] = { components: [], notes: [] } }
|
|
63
|
+
@architecture.components.each_value { buckets[_1.origin][:components] << _1 }
|
|
64
|
+
@architecture.clauses.select(&:project_wide?).each { buckets[_1.origin][:notes] << _1 }
|
|
65
|
+
|
|
66
|
+
group_index = -1
|
|
67
|
+
buckets.flat_map do |origin, bucket|
|
|
68
|
+
group = "g#{group_index += 1}" if origin
|
|
69
|
+
notes = @options.detail_at_least?(:rules) ? bucket[:notes].reject(&:drawn_as_links_only?) : []
|
|
70
|
+
nodes = bucket[:components].map { |component| node(component) }
|
|
71
|
+
nodes << note_node(group, origin ? "project-wide" : "project rules", notes) unless notes.empty?
|
|
72
|
+
next [] if nodes.empty?
|
|
73
|
+
next nodes.map { " #{_1}" } unless origin
|
|
74
|
+
|
|
75
|
+
[" subgraph #{group}[\"#{text(origin.label)}\"]", *nodes.map { " #{_1}" }, " end"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def node(component)
|
|
80
|
+
clauses = @architecture.clauses.select { _1.subject == component.name }
|
|
81
|
+
parts = ["<b>#{text(component.name)}</b>"]
|
|
82
|
+
if @options.detail_at_least?(:rules)
|
|
83
|
+
parts.concat(component.selectors.map { "<small>#{text(_1)}</small>" })
|
|
84
|
+
parts.concat(clauses.filter_map { clause_line(_1) })
|
|
85
|
+
end
|
|
86
|
+
klass = if !component.declared then :undeclared elsif clauses.any?(&:empty) then :empty end
|
|
87
|
+
@classes << klass if klass
|
|
88
|
+
"#{@component_ids.fetch(component.name)}[\"#{parts.join('<br/>')}\"]#{":::#{klass}" if klass}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def note_node(group, title, clauses)
|
|
92
|
+
@classes << :note
|
|
93
|
+
parts = ["<b>#{title}</b>", *clauses.map { clause_line(_1) }]
|
|
94
|
+
"n_#{group || 'top'}[\"#{parts.join('<br/>')}\"]:::note"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def clause_line(clause)
|
|
98
|
+
return if clause.drawn_as_links_only?
|
|
99
|
+
|
|
100
|
+
line = text(clause.summary)
|
|
101
|
+
line += "<br/><i>because #{text(clause.reason)}</i>" if clause.reason && @options.detail_at_least?(:reasons)
|
|
102
|
+
line
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def merged_edges
|
|
106
|
+
entries = @architecture.clauses.flat_map do |clause|
|
|
107
|
+
reason = clause.reason if clause.drawn_as_links_only? && @options.detail_at_least?(:reasons)
|
|
108
|
+
clause.links.map { |link| [link, reason] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
entries.group_by { |link, _| [link.from, link.to] }.flat_map do |(from, to), pair|
|
|
112
|
+
fold_consumer_into_allow(pair.group_by { |link, _| link.kind }).map do |kind, (group, base)|
|
|
113
|
+
reasons = group.filter_map(&:last).uniq.join("; ")
|
|
114
|
+
label = [base, (reasons unless reasons.empty?)].compact.join(": ")
|
|
115
|
+
Edge.new(from:, to:, kind:, label: label.empty? ? nil : label)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fold_consumer_into_allow(by_kind)
|
|
121
|
+
consumer = by_kind.key?(:allow) && by_kind.delete(:consumer)
|
|
122
|
+
by_kind.to_h do |kind, group|
|
|
123
|
+
group += consumer if consumer && kind == :allow
|
|
124
|
+
base = consumer && kind == :allow ? LINKS.fetch(:consumer)[:label] : LINKS.fetch(kind)[:label]
|
|
125
|
+
[kind, [group, base]]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def edge_line(edge)
|
|
130
|
+
arrow = LINKS.fetch(edge.kind)[:arrow]
|
|
131
|
+
target = edge.to.is_a?(Constant) ? @constant_ids.fetch(edge.to) : @component_ids.fetch(edge.to)
|
|
132
|
+
label = "|\"#{text(edge.label)}\"|" if edge.label
|
|
133
|
+
" #{@component_ids.fetch(edge.from)} #{arrow}#{label} #{target}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def link_style_lines(edges)
|
|
137
|
+
edges.each_with_index.group_by { |edge, _| LINKS.fetch(edge.kind)[:style] }.map do |style, group|
|
|
138
|
+
" linkStyle #{group.map(&:last).join(',')} #{style}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def class_def_lines = CLASS_DEFS.slice(*@classes).map { |name, style| " classDef #{name} #{style}" }
|
|
143
|
+
|
|
144
|
+
# Text inside ["…"] and edge labels: & < > " # ` become #amp; #lt; #gt;
|
|
145
|
+
# #quot; #35; #96;, and line breaks become a space. Backticks matter
|
|
146
|
+
# because a leading one turns the label into a Mermaid markdown string.
|
|
147
|
+
def text(string)
|
|
148
|
+
string.to_s.gsub(/\R/, " ").gsub(/[&<>"#`]/) { "##{ESCAPES.fetch(_1)};" }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
ESCAPES = { "&" => "amp", "<" => "lt", ">" => "gt", '"' => "quot", "#" => "35", "`" => "96" }.freeze
|
|
152
|
+
private_constant :ESCAPES
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "archspec"
|
|
2
|
+
require_relative "archspec_diagram/version"
|
|
3
|
+
|
|
4
|
+
# The whole public surface: ArchspecDiagram.render, ArchspecDiagram::Error,
|
|
5
|
+
# and ArchspecDiagram::CLI for the executable. Everything else is a private
|
|
6
|
+
# constant, so callers cannot couple to the model or the Mermaid writer.
|
|
7
|
+
module ArchspecDiagram
|
|
8
|
+
Error = Class.new(ArchSpec::Error)
|
|
9
|
+
|
|
10
|
+
class Options < Data.define(:format, :direction, :detail, :focus)
|
|
11
|
+
FORMATS = %i[mermaid markdown html].freeze
|
|
12
|
+
DIRECTIONS = %i[TB BT LR RL].freeze
|
|
13
|
+
DETAILS = %i[names rules reasons].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(format: :mermaid, direction: :TB, detail: :rules, focus: nil)
|
|
16
|
+
super(
|
|
17
|
+
format: choose("format", format, FORMATS),
|
|
18
|
+
direction: choose("direction", direction, DIRECTIONS),
|
|
19
|
+
detail: choose("detail", detail, DETAILS),
|
|
20
|
+
focus: focus&.to_sym
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def detail_at_least?(level) = DETAILS.index(detail) >= DETAILS.index(level)
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def choose(label, value, allowed)
|
|
29
|
+
choice = allowed.find { _1.to_s.casecmp?(value.to_s) }
|
|
30
|
+
return choice if choice
|
|
31
|
+
|
|
32
|
+
expected = allowed.map(&:to_s)
|
|
33
|
+
raise Error, "unknown #{label}: #{value} (expected #{expected[..-2].join(', ')} or #{expected.last})"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Reads the Archspec.rb at +path+ (or +path+/Archspec.rb for a directory)
|
|
38
|
+
# and returns the diagram as a String. Raises Error with a one-line message
|
|
39
|
+
# for every user-caused failure: bad options, unreadable or broken config,
|
|
40
|
+
# unknown focus component.
|
|
41
|
+
def self.render(path = "Archspec.rb", **options)
|
|
42
|
+
opts = Options.new(**options)
|
|
43
|
+
architecture = Architecture.load(path)
|
|
44
|
+
architecture = architecture.focus(opts.focus) if opts.focus
|
|
45
|
+
Document.new(Flowchart.new(architecture, opts), opts, title: architecture.title).to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
require_relative "archspec_diagram/architecture"
|
|
50
|
+
require_relative "archspec_diagram/clauses"
|
|
51
|
+
require_relative "archspec_diagram/flowchart"
|
|
52
|
+
require_relative "archspec_diagram/document"
|
|
53
|
+
require_relative "archspec_diagram/cli"
|
|
54
|
+
|
|
55
|
+
module ArchspecDiagram
|
|
56
|
+
private_constant :Options, :Architecture, :Clauses, :Flowchart, :Document
|
|
57
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: archspec_diagram
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- meganemura
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: archspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2'
|
|
32
|
+
executables:
|
|
33
|
+
- archspec_diagram
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- LICENSE.txt
|
|
38
|
+
- README.md
|
|
39
|
+
- exe/archspec_diagram
|
|
40
|
+
- lib/archspec_diagram.rb
|
|
41
|
+
- lib/archspec_diagram/architecture.rb
|
|
42
|
+
- lib/archspec_diagram/clauses.rb
|
|
43
|
+
- lib/archspec_diagram/cli.rb
|
|
44
|
+
- lib/archspec_diagram/document.rb
|
|
45
|
+
- lib/archspec_diagram/flowchart.rb
|
|
46
|
+
- lib/archspec_diagram/version.rb
|
|
47
|
+
homepage: https://github.com/meganemura/archspec_diagram
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata: {}
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '3.2'
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 3.6.9
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: Draw an Archspec.rb as a Mermaid flowchart
|
|
68
|
+
test_files: []
|