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 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,3 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/archspec_diagram"
3
+ exit ArchspecDiagram::CLI.start(ARGV)
@@ -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,3 @@
1
+ module ArchspecDiagram
2
+ VERSION = "0.1.0"
3
+ 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: []