graphomaton 0.1.1 → 1.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 +4 -4
- data/CHANGELOG.md +78 -3
- data/README.md +454 -27
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +284 -0
- data/lib/graphomaton/exporters/mermaid.rb +774 -0
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +284 -0
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2872 -0
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +13 -0
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2865 -240
- data/sig/graphomaton.rbs +127 -0
- metadata +39 -17
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -19
- data/sample/complex.rb +0 -24
- data/sample/nfa.rb +0 -19
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Graphomaton processes data through explicit boundaries:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
JSON / YAML / Ruby builder
|
|
7
|
+
↓
|
|
8
|
+
immutable State, Transition, and Label records
|
|
9
|
+
↓
|
|
10
|
+
validation profiles and revision-cached graph analysis
|
|
11
|
+
↓
|
|
12
|
+
exporter capability and semantic-loss checks
|
|
13
|
+
↓
|
|
14
|
+
layout → SVG scene, or syntax-specific text exporter
|
|
15
|
+
↓
|
|
16
|
+
optional bounded native conversion to PNG, PDF, or WebP
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The public builder mutates the graph only through validated methods and increments
|
|
20
|
+
`revision` after every effective change. Analysis indexes and layouts are rebuilt
|
|
21
|
+
only when the revision changes. Exporters consume immutable record snapshots; the
|
|
22
|
+
legacy `states` and `transitions` readers expose frozen compatibility hashes.
|
|
23
|
+
|
|
24
|
+
SVG is the native visual format. Raster and PDF exporters keep SVG logical
|
|
25
|
+
coordinates stable and adjust only output pixel dimensions. DOT, Mermaid, and
|
|
26
|
+
PlantUML allocate internal identifiers independently from labels. Every exporter
|
|
27
|
+
declares its capabilities so unsupported semantics can be diagnosed or rejected.
|
|
28
|
+
|
|
29
|
+
External processes run in their own process group with a timeout and stdout/stderr
|
|
30
|
+
limits. File output uses a same-directory temporary file and atomic rename.
|
data/docs/cli.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Command-line reference
|
|
2
|
+
|
|
3
|
+
`graphomaton render INPUT OUTPUT` is the explicit form of the compatible
|
|
4
|
+
`graphomaton INPUT OUTPUT` command. Use `-` for stdin or stdout; stdout requires
|
|
5
|
+
`--format`. Rendering validates references by default.
|
|
6
|
+
|
|
7
|
+
`graphomaton validate INPUT --diagnostics json` validates references, hierarchy,
|
|
8
|
+
FSM warnings, and DFA constraints without writing a diagram. Add
|
|
9
|
+
`--fail-on-warning` for CI. `graphomaton list` reports formats, layouts, themes,
|
|
10
|
+
and converters; `doctor` reports the Ruby version plus discovered native renderer
|
|
11
|
+
paths and bounded version probes.
|
|
12
|
+
|
|
13
|
+
Configuration resolution is defaults, `.graphomaton.yml`, environment, then CLI.
|
|
14
|
+
`GRAPHOMATON_CONFIG` selects another config. Supported scalar environment values
|
|
15
|
+
are `GRAPHOMATON_FORMAT`, `GRAPHOMATON_THEME`, `GRAPHOMATON_LAYOUT`,
|
|
16
|
+
`GRAPHOMATON_WIDTH`, and `GRAPHOMATON_HEIGHT`.
|
|
17
|
+
|
|
18
|
+
Exit statuses are stable: 0 success, 2 usage, 3 input, 4 validation, 5 layout, 6
|
|
19
|
+
export/conversion, and 7 security policy. Normal failures omit backtraces; pass
|
|
20
|
+
`--debug` while investigating.
|
|
21
|
+
|
|
22
|
+
Input budgets can be lowered with `--max-input-bytes`, `--max-states`,
|
|
23
|
+
`--max-transitions`, `--max-metadata-depth`, `--max-label-length`, and
|
|
24
|
+
`--max-group-depth`.
|
|
25
|
+
|
|
26
|
+
Generate integration files with `graphomaton completion bash|zsh|fish` and
|
|
27
|
+
`graphomaton man`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Custom exporters
|
|
2
|
+
|
|
3
|
+
Applications can register an exporter class with a canonical format name, aliases,
|
|
4
|
+
filename extensions, binary mode, and the semantics it preserves:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
class TextExporter
|
|
8
|
+
def initialize(graph)
|
|
9
|
+
@graph = graph
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def export(width, height, prefix: 'graph')
|
|
13
|
+
"#{prefix}: #{@graph.state_records.size} states on #{width}x#{height}\n"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Graphomaton.register_exporter(
|
|
18
|
+
:text_graph,
|
|
19
|
+
aliases: %i[tg],
|
|
20
|
+
extensions: %w[textgraph tg],
|
|
21
|
+
capabilities: %i[group],
|
|
22
|
+
exporter: TextExporter
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
graph.render(format: :tg, prefix: 'machine')
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
An exporter class must accept the graph in `initialize` and implement
|
|
29
|
+
`export(width, height, **options)`. A loader block can be used instead of the
|
|
30
|
+
`exporter:` argument to defer loading optional dependencies. Registration rejects
|
|
31
|
+
names, aliases, and extensions that another exporter already owns.
|
|
32
|
+
|
|
33
|
+
Capability omissions are reported by `semantic_diagnostics` and rejected when
|
|
34
|
+
`strict_semantics: true` is used. Set `binary: true` when output must be written in
|
|
35
|
+
binary mode. A temporary registration can be removed with
|
|
36
|
+
`Graphomaton::EXPORTERS.unregister(:text_graph)`.
|
data/docs/exporters.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Exporter capabilities
|
|
2
|
+
|
|
3
|
+
| Feature | SVG/PNG/PDF/WebP | DOT | Mermaid | PlantUML |
|
|
4
|
+
| --- | --- | --- | --- | --- |
|
|
5
|
+
| styles and line styles | yes | partial | partial | partial |
|
|
6
|
+
| URL and tooltip | yes | yes | no | no |
|
|
7
|
+
| visual groups | yes | yes | partial | partial |
|
|
8
|
+
| hierarchy and pseudostates | display | partial | yes | yes |
|
|
9
|
+
| edge bundles | yes | no | no | no |
|
|
10
|
+
|
|
11
|
+
Use `semantic_diagnostics(format)` to inspect loss, `strict_semantics: true` to
|
|
12
|
+
reject it, and `render_result` to obtain output, diagnostics, bounds, and resolved
|
|
13
|
+
SVG layout together.
|
|
14
|
+
|
|
15
|
+
`group` is a visual cluster. `parent` is a semantic hierarchy relationship. They
|
|
16
|
+
cannot be assigned to the same state. Graph analysis follows transitions and does
|
|
17
|
+
not infer statechart execution semantics from visual groups.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Input schema version 1
|
|
2
|
+
|
|
3
|
+
The top-level mapping accepts `version`, `states`, `transitions`, `initial` (or
|
|
4
|
+
`initial_state`), and `final` (or `final_states`). Unknown keys are errors by
|
|
5
|
+
default. `version` is optional for compatibility and, when present, must be `1`.
|
|
6
|
+
|
|
7
|
+
States may be an array or a mapping keyed by state ID. A state mapping accepts
|
|
8
|
+
`id`, `name`, `x`, `y`, `label`, `style`, `metadata`, `shape`, `kind`, `initial`,
|
|
9
|
+
`final`, and `accepting`. `kind` is `normal`, `choice`, `fork`, or `join`. State IDs
|
|
10
|
+
must be unique. `initial` is singular. `metadata.parent`
|
|
11
|
+
must name an existing state and may not form a cycle or coexist with a visual
|
|
12
|
+
`group`.
|
|
13
|
+
|
|
14
|
+
Transitions are an array. Each item is either a mapping with `from`, `to`, and
|
|
15
|
+
`label`, or a three-element tuple. Mapping options are `style`, `metadata`, and
|
|
16
|
+
`line_style`. Reference validation is deferred in the Ruby library and enabled by
|
|
17
|
+
default in the CLI.
|
|
18
|
+
|
|
19
|
+
State and transition `style` and `metadata` values must be mappings. State labels
|
|
20
|
+
are scalar display text. Transition labels may be scalar text, a symbol array, or
|
|
21
|
+
a structured label with an explicit `type`/`kind`; malformed collections are
|
|
22
|
+
rejected instead of being stringified or ignored.
|
|
23
|
+
|
|
24
|
+
YAML aliases are disabled. Parsing limits input bytes, state and transition
|
|
25
|
+
counts, label bytes, metadata depth, and hierarchy depth. Callers handling
|
|
26
|
+
untrusted data should lower the defaults for their service budget.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Migrating to 1.1
|
|
2
|
+
|
|
3
|
+
Builder methods now return the graph, enabling chaining. Duplicate `add_state`
|
|
4
|
+
calls raise; use `upsert_state` for replacement. Public `states` and `transitions`
|
|
5
|
+
are frozen compatibility snapshots, so mutations must use update/remove methods.
|
|
6
|
+
An upsert that omits coordinates preserves an existing manual position; pass both
|
|
7
|
+
`x` and `y` as `nil` to clear it explicitly.
|
|
8
|
+
|
|
9
|
+
Input schema keys are strict by default and YAML aliases are disabled. Pass
|
|
10
|
+
`strict_schema: false` only while migrating legacy documents, and enable aliases
|
|
11
|
+
only for trusted YAML.
|
|
12
|
+
|
|
13
|
+
The CLI validates references by default, rejects options unsupported by the
|
|
14
|
+
selected format, and assigns distinct exit statuses. Use `--no-validate` only for
|
|
15
|
+
intentional partial diagrams.
|
|
16
|
+
|
|
17
|
+
Semantic loss is reported for exporters that cannot preserve requested metadata.
|
|
18
|
+
Use `strict_semantics: true` or `--strict-semantics` when silent degradation is not
|
|
19
|
+
acceptable.
|
data/docs/performance.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Performance and limits
|
|
2
|
+
|
|
3
|
+
Input defaults are 10 MiB, 10,000 states, 100,000 transitions, metadata depth 64,
|
|
4
|
+
and hierarchy depth 64. Canvas area and force iteration counts are bounded. Set
|
|
5
|
+
lower limits at untrusted service boundaries.
|
|
6
|
+
|
|
7
|
+
Graph analysis uses revision-cached incoming, outgoing, and transition-pair
|
|
8
|
+
indexes. SCC traversal is iterative. Repeated layouts with identical options are
|
|
9
|
+
cached until the graph changes. Force layout switches from exact pair repulsion
|
|
10
|
+
to a Barnes-Hut quadtree for large graphs. SVG label collision uses a bounded
|
|
11
|
+
uniform-grid index; unusually large boxes fall back to a bounded overflow list.
|
|
12
|
+
|
|
13
|
+
Run `ruby -Ilib benchmark/render_svg.rb [COUNTS...]` to measure 100, 1,000, and
|
|
14
|
+
10,000 transition renders on the local runtime. This benchmark deliberately does
|
|
15
|
+
not set a pass/fail time because Ruby, CPU, and renderer configurations vary.
|
|
16
|
+
|
|
17
|
+
REXML builds the complete SVG DOM in memory. Very large outputs therefore need
|
|
18
|
+
memory proportional to generated SVG. Native conversions additionally enforce a
|
|
19
|
+
timeout and output byte limit.
|
data/docs/releasing.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Release policy
|
|
2
|
+
|
|
3
|
+
Graphomaton follows semantic versioning. Patch releases contain compatible fixes,
|
|
4
|
+
minor releases contain compatible features and announced deprecations, and major
|
|
5
|
+
releases may remove deprecated behavior. Security fixes target the latest release
|
|
6
|
+
as described in `SECURITY.md`.
|
|
7
|
+
|
|
8
|
+
To release:
|
|
9
|
+
|
|
10
|
+
1. Move completed entries from Unreleased to a dated version section.
|
|
11
|
+
2. Set `Graphomaton::VERSION` to the same version and run `bundle exec rake`.
|
|
12
|
+
3. Build and locally install the gem and smoke-test `graphomaton --version`.
|
|
13
|
+
4. Push the signed or annotated `vVERSION` tag.
|
|
14
|
+
|
|
15
|
+
The tag workflow verifies the tag/version match, reruns tests and RBS validation,
|
|
16
|
+
builds and installs the package, then uses RubyGems trusted publishing with a
|
|
17
|
+
short-lived OIDC token. The RubyGems publisher must be configured for
|
|
18
|
+
`.github/workflows/release.yml` and the protected `release` environment. No
|
|
19
|
+
long-lived RubyGems API key is stored in the repository.
|
data/exe/graphomaton
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
class Graphomaton
|
|
6
|
+
class AtomicFile
|
|
7
|
+
def self.write(filename, content, binary: false)
|
|
8
|
+
destination = File.expand_path(filename)
|
|
9
|
+
directory = File.dirname(destination)
|
|
10
|
+
basename = File.basename(destination)
|
|
11
|
+
existing_mode = File.stat(destination).mode & 0o7777 if File.exist?(destination)
|
|
12
|
+
|
|
13
|
+
Tempfile.create([".#{basename}", '.tmp'], directory, binmode: binary) do |temporary|
|
|
14
|
+
temporary.binmode if binary
|
|
15
|
+
temporary.chmod(existing_mode) if existing_mode
|
|
16
|
+
temporary.write(content)
|
|
17
|
+
temporary.flush
|
|
18
|
+
temporary.fsync
|
|
19
|
+
temporary.close
|
|
20
|
+
File.rename(temporary.path, destination)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
content.bytesize
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
class CLI
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_PATH = '.graphomaton.yml'
|
|
7
|
+
MAX_BYTES = 1024 * 1024
|
|
8
|
+
FORMAT_SECTIONS = %i[svg png pdf webp html mermaid dot plantuml].freeze
|
|
9
|
+
ENUM_KEYS = %i[
|
|
10
|
+
format input_format theme layout direction fit converter initial_position final_position
|
|
11
|
+
state_shape edge_style arrow_shape state_effect unreachable_zone loop_position diagnostics
|
|
12
|
+
].freeze
|
|
13
|
+
OPTION_KEYS = %i[
|
|
14
|
+
input input_format output format validate no_clobber width height scale converter timeout
|
|
15
|
+
max_output_bytes max_input_bytes max_states max_transitions max_metadata_depth max_label_length
|
|
16
|
+
max_group_depth theme theme_file layout_warnings
|
|
17
|
+
layout direction fit padding node_spacing rank_spacing force_iterations layout_seed
|
|
18
|
+
graphviz_command auto_density_spacing initial_position final_position responsive state_radius
|
|
19
|
+
auto_state_radius min_state_radius max_state_radius state_stroke_width transition_stroke_width
|
|
20
|
+
state_shape edge_style arrow_shape arrow_size state_effect font_family state_font_weight
|
|
21
|
+
transition_font_weight preserve_manual_positions auto_size xml_declaration pretty minify
|
|
22
|
+
css_variables embed_styles svg_id wrap max_transition_label_width state_wrap
|
|
23
|
+
max_state_label_width label_tooltips html_tooltips sort_labels rotate_labels label_padding
|
|
24
|
+
label_radius label_border label_background initial_arrow_length initial_arrow_label
|
|
25
|
+
final_arrow_length final_arrow_label show_final_arrows scc_groups fold_groups
|
|
26
|
+
highlight_unreachable unreachable_zone highlight_dead_states highlight_initial_state
|
|
27
|
+
highlight_final_states loop_position merge_parallel_transitions title description cdn offline
|
|
28
|
+
inline_mermaid lang show_source pan_zoom mathjax mathjax_cdn notes class_defs rank_constraints
|
|
29
|
+
inline_mathjax self_contained nonce csp mermaid_sha256 mathjax_sha256
|
|
30
|
+
fail_on_warning diagnostics strict_semantics
|
|
31
|
+
].freeze
|
|
32
|
+
LABEL_KEYS = {
|
|
33
|
+
wrap: :wrap,
|
|
34
|
+
max_width: :max_transition_label_width,
|
|
35
|
+
max_transition_width: :max_transition_label_width,
|
|
36
|
+
state_wrap: :state_wrap,
|
|
37
|
+
max_state_width: :max_state_label_width,
|
|
38
|
+
tooltips: :label_tooltips,
|
|
39
|
+
html_tooltips: :html_tooltips,
|
|
40
|
+
rotate: :rotate_labels,
|
|
41
|
+
padding: :label_padding,
|
|
42
|
+
radius: :label_radius,
|
|
43
|
+
border: :label_border,
|
|
44
|
+
background: :label_background
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def self.load(path, format: nil, required: false)
|
|
48
|
+
return {} if path.nil?
|
|
49
|
+
return {} unless File.exist?(path) || required
|
|
50
|
+
|
|
51
|
+
payload = File.open(path, 'rb') { |input| input.read(MAX_BYTES + 1) }
|
|
52
|
+
raise ArgumentError, "Config exceeds #{MAX_BYTES} bytes" if payload.bytesize > MAX_BYTES
|
|
53
|
+
|
|
54
|
+
parsed = YAML.safe_load(payload, permitted_classes: [Symbol], aliases: false) || {}
|
|
55
|
+
raise ArgumentError, 'Config root must be a mapping' unless parsed.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
normalized = symbolize_hash(parsed)
|
|
58
|
+
unknown_sections = normalized.keys - OPTION_KEYS - [:render] - FORMAT_SECTIONS
|
|
59
|
+
raise ArgumentError, "Unknown config keys: #{unknown_sections.join(', ')}" unless unknown_sections.empty?
|
|
60
|
+
|
|
61
|
+
common = normalized.reject { |key, _value| key == :render || FORMAT_SECTIONS.include?(key) }
|
|
62
|
+
render = mapping(normalized[:render], 'render')
|
|
63
|
+
format_options = format ? mapping(normalized[format.to_sym], format) : {}
|
|
64
|
+
normalize_options(common.merge(render).merge(format_options))
|
|
65
|
+
rescue Psych::Exception, SystemCallError => e
|
|
66
|
+
raise ArgumentError, "Config error: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.mapping(value, name)
|
|
70
|
+
return {} if value.nil?
|
|
71
|
+
raise ArgumentError, "Config #{name} section must be a mapping" unless value.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
value
|
|
74
|
+
end
|
|
75
|
+
private_class_method :mapping
|
|
76
|
+
|
|
77
|
+
def self.normalize_options(input)
|
|
78
|
+
labels = mapping(input.delete(:labels), 'labels')
|
|
79
|
+
unknown_labels = labels.keys - LABEL_KEYS.keys
|
|
80
|
+
raise ArgumentError, "Unknown config label keys: #{unknown_labels.join(', ')}" unless unknown_labels.empty?
|
|
81
|
+
|
|
82
|
+
options = input.merge(labels.to_h { |key, value| [LABEL_KEYS.fetch(key), value] })
|
|
83
|
+
unknown = options.keys - OPTION_KEYS
|
|
84
|
+
raise ArgumentError, "Unknown config option keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
85
|
+
|
|
86
|
+
options.to_h do |key, value|
|
|
87
|
+
normalized = ENUM_KEYS.include?(key) && value.is_a?(String) ? value.to_sym : value
|
|
88
|
+
[key, normalized]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
private_class_method :normalize_options
|
|
92
|
+
|
|
93
|
+
def self.symbolize_hash(value)
|
|
94
|
+
value.to_h do |key, item|
|
|
95
|
+
normalized = item.is_a?(Hash) ? symbolize_hash(item) : item
|
|
96
|
+
[key.to_s.to_sym, normalized]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_class_method :symbolize_hash
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|