Module: Scjson

Defined in:
lib/scjson.rb,
lib/scjson/cli.rb,
lib/scjson/types.rb,
lib/scjson/engine.rb,
lib/scjson/version.rb,
lib/scjson/engine/context.rb

Overview

Agent Name: ruby-scjson-version

Part of the scjson project. Developed by Softoboros Technology Inc. Licensed under the BSD 1-Clause License.

Defined Under Namespace

Modules: Engine, Types

Constant Summary collapse

XMLNS =
'http://www.w3.org/2005/07/scxml'.freeze
XINCLUDE_NS =
'http://www.w3.org/2001/XInclude'.freeze
XINCLUDE_CLARK_INCLUDE =
"{#{XINCLUDE_NS}}include".freeze
ATTRIBUTE_MAP =
{
  'datamodel' => 'datamodel_attribute',
  'initial' => 'initial_attribute',
  'type' => 'type_value',
  'raise' => 'raise_value'
}.freeze
COLLAPSE_ATTRS =
%w[expr cond event target delay location name src id].freeze
SCXML_ELEMENTS =
%w[
  scxml state parallel final history transition invoke finalize datamodel data
  onentry onexit log send cancel raise assign script foreach param if elseif
  else content donedata initial
].freeze
SOURCE_BODY_TAGS =
%w[script data].freeze
STRUCTURAL_FIELDS =
%w[
  state parallel final history transition invoke finalize datamodel data
  onentry onexit log send cancel raise assign script foreach param if_value
  elseif else_value raise_value content donedata initial
].freeze
PRESERVE_EMPTY_KEYS =
%w[expr cond event target id name label text].freeze
ALWAYS_KEEP_KEYS =
%w[else_value else final onentry].freeze
VERSION =
'0.4.0'

Class Method Summary collapse

Class Method Details

.convert_scjson_file(src, dest, verify) ⇒ void

This method returns an undefined value.

Convert a single scjson document to SCXML.

Parameters:

  • src (String, Pathname)

    Input scjson file path.

  • dest (Pathname)

    Target SCXML file path.

  • verify (Boolean)

    When true, only validate round-tripping without writing.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/scjson/cli.rb', line 162

def self.convert_scjson_file(src, dest, verify)
  json_str = File.read(src)
  begin
    xml_str = Scjson.json_to_xml(json_str)
    if verify
      Scjson.xml_to_json(xml_str)
      puts "Verified #{src}"
    else
      FileUtils.mkdir_p(dest.dirname)
      File.write(dest, xml_str)
      puts "Wrote #{dest}"
    end
  rescue StandardError => e
    warn "Failed to convert #{src}: #{e}"
  end
end

.convert_scxml_file(src, dest, verify, keep_empty) ⇒ void

This method returns an undefined value.

Convert a single SCXML document to scjson.

Parameters:

  • src (String, Pathname)

    Input SCXML file path.

  • dest (Pathname)

    Target path for scjson output.

  • verify (Boolean)

    When true, only validate round-tripping without writing.

  • keep_empty (Boolean)

    When true, retain empty containers in JSON output.



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/scjson/cli.rb', line 138

def self.convert_scxml_file(src, dest, verify, keep_empty)
  xml_str = File.read(src)
  begin
    json_str = Scjson.xml_to_json(xml_str, !keep_empty)
    if verify
      Scjson.json_to_xml(json_str)
      puts "Verified #{src}"
    else
      FileUtils.mkdir_p(dest.dirname)
      File.write(dest, json_str)
      puts "Wrote #{dest}"
    end
  rescue StandardError => e
    warn "Failed to convert #{src}: #{e}"
  end
end

.engine_trace(argv) ⇒ void

This method returns an undefined value.

Emit a standardized JSONL execution trace for a document.

Mirrors the Python CLI flags where appropriate, using Ruby idioms.

Parameters:

  • argv (Array<String>)

    Command line arguments following ‘engine-trace’.



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/scjson/cli.rb', line 231

def self.engine_trace(argv)
  input = nil
  events = nil
  out = nil
  is_xml = false
  leaf_only = false
  omit_actions = false
  omit_delta = false
  omit_transitions = false
  advance_time = 0.0
  ordering = 'tolerant'
  max_steps = nil
  strip_step0_noise = false
  strip_step0_states = false
  keep_cond = false
  defer_done = true

  parser = OptionParser.new do |opts|
    opts.banner = 'scjson engine-trace [options]'
    opts.on('-I', '--input PATH', 'SCJSON/SCXML document') { |v| input = v }
    opts.on('-e', '--events PATH', 'JSONL stream of events') { |v| events = v }
    opts.on('-o', '--out PATH', 'Destination trace file (stdout by default)') { |v| out = v }
    opts.on('--xml', 'Treat input as SCXML') { is_xml = true }
    opts.on('--leaf-only', 'Restrict configuration/entered/exited to leaf states') { leaf_only = true }
    opts.on('--omit-actions', 'Omit actionLog entries from the trace') { omit_actions = true }
    opts.on('--omit-delta', 'Omit datamodelDelta entries from the trace') { omit_delta = true }
    opts.on('--omit-transitions', 'Omit firedTransitions entries from the trace') { omit_transitions = true }
    opts.on('--advance-time N', Float, 'Advance time by N seconds before processing events') { |v| advance_time = v }
    opts.on('--ordering MODE', ['tolerant', 'strict', 'scion'], 'Ordering policy (tolerant|strict|scion)') { |v| ordering = v }
    opts.on('--defer-done', 'Defer done.invoke processing to next step (default)') { defer_done = true }
    opts.on('--no-defer-done', 'Process done.invoke within the same step') { defer_done = false }
    opts.on('--max-steps N', Integer, 'Maximum steps to process') { |v| max_steps = v }
    opts.on('--strip-step0-noise', 'Clear datamodelDelta and firedTransitions at step 0') { strip_step0_noise = true }
    opts.on('--strip-step0-states', 'Clear enteredStates and exitedStates at step 0') { strip_step0_states = true }
    opts.on('--keep-cond', 'Keep transition cond fields (default scrubs cond)') { keep_cond = true }
    opts.on('-h', '--help', 'Show help') do
      puts opts
      return
    end
  end

  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    warn e.message
    puts parser
    return
  end
  unless input
    warn 'Missing required --input'
    puts parser
    return
  end

  Scjson::Engine.trace(
    input_path: input,
    events_path: events,
    out_path: out,
    xml: is_xml,
    leaf_only: leaf_only,
    omit_actions: omit_actions,
    omit_delta: omit_delta,
    omit_transitions: omit_transitions,
    advance_time: advance_time,
    ordering: ordering,
    max_steps: max_steps,
    strip_step0_noise: strip_step0_noise,
    strip_step0_states: strip_step0_states,
    keep_cond: keep_cond,
    defer_done: defer_done
  )
end

.handle_json(path, opt) ⇒ void

This method returns an undefined value.

Convert SCXML inputs to scjson outputs.

Handles both file and directory inputs, preserving relative paths when writing to directories.

Parameters:

  • path (Pathname)

    Source file or directory.

  • opt (Hash)

    Options hash controlling output and recursion behaviour.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/scjson/cli.rb', line 82

def self.handle_json(path, opt)
  if path.directory?
    out_dir = opt[:output] ? Pathname.new(opt[:output]) : path
    pattern = opt[:recursive] ? '**/*.scxml' : '*.scxml'
    Dir.glob(path.join(pattern).to_s).each do |src|
      rel = Pathname.new(src).relative_path_from(path)
      dest = out_dir.join(rel).sub_ext('.scjson')
      convert_scxml_file(src, dest, opt[:verify], opt[:keep_empty])
    end
  else
    dest = if opt[:output]
             p = Pathname.new(opt[:output])
             p.directory? ? p.join(path.basename.sub_ext('.scjson')) : p
           else
             path.sub_ext('.scjson')
           end
    convert_scxml_file(path, dest, opt[:verify], opt[:keep_empty])
  end
end

.handle_xml(path, opt) ⇒ void

This method returns an undefined value.

Convert scjson inputs to SCXML outputs.

Handles both file and directory inputs.

Parameters:

  • path (Pathname)

    Source file or directory.

  • opt (Hash)

    Options hash controlling output and recursion behaviour.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/scjson/cli.rb', line 110

def self.handle_xml(path, opt)
  if path.directory?
    out_dir = opt[:output] ? Pathname.new(opt[:output]) : path
    pattern = opt[:recursive] ? '**/*.scjson' : '*.scjson'
    Dir.glob(path.join(pattern).to_s).each do |src|
      rel = Pathname.new(src).relative_path_from(path)
      dest = out_dir.join(rel).sub_ext('.scxml')
      convert_scjson_file(src, dest, opt[:verify])
    end
  else
    dest = if opt[:output]
             p = Pathname.new(opt[:output])
             p.directory? ? p.join(path.basename.sub_ext('.scxml')) : p
           else
             path.sub_ext('.scxml')
           end
    convert_scjson_file(path, dest, opt[:verify])
  end
end

.help_textString

Render the help text describing CLI usage.

Returns:

  • (String)

    A one-line summary of the CLI purpose.



69
70
71
# File 'lib/scjson/cli.rb', line 69

def self.help_text
  'scjson - SCXML <-> scjson converter, validator, and engine trace'
end

.json_to_xml(json_str) ⇒ String

Convert a canonical scjson document back to SCXML.

Parameters:

  • json_str (String)

    Canonical scjson input.

Returns:

  • (String)

    XML document encoded as UTF-8.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/scjson.rb', line 102

def json_to_xml(json_str)
  if NOKOGIRI_AVAILABLE
    data = JSON.parse(json_str)
    remove_empty(data)
    doc = Nokogiri::XML::Document.new
    doc.encoding = 'utf-8'
    root = build_element(doc, 'scxml', data)
    doc.root = root
    add_preceding_help_text_comments(doc, root, data)
    return doc.to_xml
  end
  # Fallback: use Python CLI converter when Nokogiri is unavailable.
  begin
    require 'tmpdir'
    Dir.mktmpdir('scjson-rb-json2xml') do |dir|
      in_path = File.join(dir, 'in.scjson')
      out_path = File.join(dir, 'out.scxml')
      File.write(in_path, json_str)
      py_candidates = [ENV['PYTHON'], 'python3', 'python'].compact.uniq
      ok = false
      py_candidates.each do |py|
        repo_py = File.expand_path('../../py', __dir__)
        env = {}
        current_pp = ENV['PYTHONPATH']
        env['PYTHONPATH'] = current_pp ? (repo_py + File::PATH_SEPARATOR + current_pp) : repo_py
        cmd = [py, '-m', 'scjson.cli', 'xml', in_path, '-o', out_path]
        ok = system(env, *cmd, out: File::NULL, err: File::NULL) && File.file?(out_path)
        break if ok
      end
      raise 'python converter failed' unless ok
      return File.read(out_path)
    end
  rescue StandardError => e
    raise LoadError, "JSON->SCXML conversion unavailable: Nokogiri missing and external converter failed (#{e})"
  end
end

.main(argv = ARGV) ⇒ void

This method returns an undefined value.

Command line interface for scjson conversions.

Parameters:

  • argv (Array<String>) (defaults to: ARGV)

    Command line arguments provided by the user.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/scjson/cli.rb', line 29

def self.main(argv = ARGV)
  options = { recursive: false, verify: false, keep_empty: false }
  cmd = argv.shift
  if cmd.nil? || %w[-h --help].include?(cmd)
    puts(help_text)
    return
  end
  if cmd == 'engine-trace'
    return engine_trace(argv)
  end
  parser = OptionParser.new do |opts|
    opts.banner = ''
    opts.on('-o', '--output PATH', 'output file or directory') { |v| options[:output] = v }
    opts.on('-r', '--recursive', 'recurse into directories') { options[:recursive] = true }
    opts.on('-v', '--verify', 'verify conversion without writing output') { options[:verify] = true }
    opts.on('--keep-empty', 'keep null or empty items when producing JSON') { options[:keep_empty] = true }
  end
  path = argv.shift
  parser.parse!(argv)
  unless path
    puts(help_text)
    return
  end
  splash
  case cmd
  when 'json'
    handle_json(Pathname.new(path), options)
  when 'xml'
    handle_xml(Pathname.new(path), options)
  when 'validate'
    validate(Pathname.new(path), options[:recursive])
  else
    puts(help_text)
  end
end

.splashvoid

This method returns an undefined value.

Display the CLI program header.



20
21
22
# File 'lib/scjson/cli.rb', line 20

def self.splash
  puts "scjson #{VERSION} - SCXML/SCML execution, SCXML <-> scjson converter & validator"
end

.validate(path, recursive) ⇒ void

This method returns an undefined value.

Validate a file or directory tree of SCXML and scjson documents.

Parameters:

  • path (Pathname)

    File or directory to validate.

  • recursive (Boolean)

    When true, traverse subdirectories.



185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/scjson/cli.rb', line 185

def self.validate(path, recursive)
  success = true
  if path.directory?
    pattern = recursive ? '**/*' : '*'
    Dir.glob(path.join(pattern).to_s).each do |f|
      next unless File.file?(f)
      next unless f.end_with?('.scxml', '.scjson')
      success &= validate_file(f)
    end
  else
    success &= validate_file(path.to_s)
  end
  exit(1) unless success
end

.validate_file(src) ⇒ Boolean

Validate a single SCXML or scjson document by round-tripping.

Parameters:

  • src (String)

    Path to the document to validate.

Returns:

  • (Boolean)

    True when the document validates successfully.



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/scjson/cli.rb', line 205

def self.validate_file(src)
  begin
    data = File.read(src)
    if src.end_with?('.scxml')
      json = Scjson.xml_to_json(data)
      Scjson.json_to_xml(json)
    elsif src.end_with?('.scjson')
      xml = Scjson.json_to_xml(data)
      Scjson.xml_to_json(xml)
    else
      return true
    end
    true
  rescue StandardError => e
    warn "Validation failed for #{src}: #{e}"
    false
  end
end

.xml_to_json(xml_str, omit_empty = true) ⇒ String

Convert an SCXML document to its canonical scjson form.

Parameters:

  • xml_str (String)

    SCXML source document.

  • omit_empty (Boolean) (defaults to: true)

    Remove empty containers when true.

Returns:

  • (String)

    Canonical scjson output.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/scjson.rb', line 58

def xml_to_json(xml_str, omit_empty = true)
  if NOKOGIRI_AVAILABLE
    doc = Nokogiri::XML(xml_str) { |cfg| cfg.strict.nonet }
    root = locate_root(doc)
    raise ArgumentError, 'Document missing <scxml> root element' unless root

    map = element_to_hash(root)
    attach_root_sibling_comments(doc, root, map)
    collapse_whitespace(map)
    remove_empty(map) if omit_empty
    return JSON.pretty_generate(map)
  end
  # Fallback: use Python CLI converter when Nokogiri is unavailable.
  begin
    require 'tmpdir'
    Dir.mktmpdir('scjson-rb-xml2json') do |dir|
      in_path = File.join(dir, 'in.scxml')
      out_path = File.join(dir, 'out.scjson')
      File.write(in_path, xml_str)
      py_candidates = [ENV['PYTHON'], 'python3', 'python'].compact.uniq
      ok = false
      py_candidates.each do |py|
        # Try package entrypoint; add repo-local 'py' to PYTHONPATH for import
        repo_py = File.expand_path('../../py', __dir__)
        env = {}
        current_pp = ENV['PYTHONPATH']
        env['PYTHONPATH'] = current_pp ? (repo_py + File::PATH_SEPARATOR + current_pp) : repo_py
        cmd = [py, '-m', 'scjson.cli', 'json', in_path, '-o', out_path]
        ok = system(env, *cmd, out: File::NULL, err: File::NULL) && File.file?(out_path)
        break if ok
      end
      raise 'python converter failed' unless ok
      return File.read(out_path)
    end
  rescue StandardError => e
    raise LoadError, "SCXML->JSON conversion unavailable: Nokogiri missing and external converter failed (#{e})"
  end
end