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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -3
  3. data/README.md +454 -27
  4. data/SECURITY.md +47 -0
  5. data/docs/architecture.md +30 -0
  6. data/docs/cli.md +27 -0
  7. data/docs/custom-exporters.md +36 -0
  8. data/docs/exporters.md +17 -0
  9. data/docs/input-schema.md +26 -0
  10. data/docs/migration-1.1.md +19 -0
  11. data/docs/performance.md +19 -0
  12. data/docs/releasing.md +19 -0
  13. data/exe/graphomaton +9 -0
  14. data/lib/graphomaton/atomic_file.rb +26 -0
  15. data/lib/graphomaton/cli/config.rb +102 -0
  16. data/lib/graphomaton/cli.rb +841 -0
  17. data/lib/graphomaton/errors.rb +11 -0
  18. data/lib/graphomaton/exporter_registry.rb +127 -0
  19. data/lib/graphomaton/exporters/dot.rb +284 -0
  20. data/lib/graphomaton/exporters/mermaid.rb +774 -0
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +284 -0
  23. data/lib/graphomaton/exporters/png.rb +172 -0
  24. data/lib/graphomaton/exporters/svg.rb +2872 -0
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +13 -0
  27. data/lib/graphomaton/identifier_allocator.rb +33 -0
  28. data/lib/graphomaton/input_policy.rb +82 -0
  29. data/lib/graphomaton/layout/force_tree.rb +127 -0
  30. data/lib/graphomaton/model.rb +218 -0
  31. data/lib/graphomaton/process_runner.rb +154 -0
  32. data/lib/graphomaton/url_policy.rb +40 -0
  33. data/lib/graphomaton/version.rb +1 -1
  34. data/lib/graphomaton.rb +2865 -240
  35. data/sig/graphomaton.rbs +127 -0
  36. metadata +39 -17
  37. data/.codespellignore +0 -0
  38. data/.rspec +0 -1
  39. data/CODE_OF_CONDUCT.md +0 -132
  40. data/Rakefile +0 -8
  41. data/sample/basic.rb +0 -19
  42. data/sample/complex.rb +0 -24
  43. data/sample/nfa.rb +0 -19
  44. data/spec/graphomaton_spec.rb +0 -371
  45. 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.
@@ -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,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib_path = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib_path) if File.directory?(lib_path)
6
+
7
+ require 'graphomaton/cli'
8
+
9
+ exit Graphomaton::CLI.new.run(ARGV)
@@ -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