Module: Kumi::Dev::GoldenRuntime

Defined in:
lib/kumi/dev/golden_runtime.rb

Overview

Executes a golden schema’s generated Ruby AND JavaScript against its input.json and returns a canonical JSON of the outputs — and, crucially, asserts the two targets agree (Kumi’s core bit-identical guarantee).

Used by golden_v2’s ‘runtime` representation: `update` snapshots the outputs, `verify` re-runs and diffs (catching output regressions) while also re-checking Ruby/JS parity. This is the execution coverage that the old v1 golden RuntimeTest provided, folded into v2.

Constant Summary collapse

JS_RUNNER =
File.expand_path("support/kumi_runner.mjs", __dir__)

Class Method Summary collapse

Class Method Details

.assert_parity!(ruby_out, js_out, schema_path) ⇒ Object



114
115
116
117
118
119
120
# File 'lib/kumi/dev/golden_runtime.rb', line 114

def assert_parity!(ruby_out, js_out, schema_path)
  rk = canonical(ruby_out)
  jk = canonical(js_out)
  return if rk == jk

  raise "Ruby/JS output mismatch for #{schema_path}:\n  ruby: #{rk}\n  js:   #{jk}"
end

.canonical(value) ⇒ Object

Stable, normalized JSON: BigDecimals to floats, keys sorted, so snapshots are deterministic and Ruby/JS compare equal.



124
125
126
# File 'lib/kumi/dev/golden_runtime.rb', line 124

def canonical(value)
  JSON.pretty_generate(normalize(value))
end

.convert_decimal_strings(value) ⇒ Object



144
145
146
147
148
149
150
151
152
# File 'lib/kumi/dev/golden_runtime.rb', line 144

def convert_decimal_strings(value)
  case value
  when Hash  then value.transform_values { |v| convert_decimal_strings(v) }
  when Array then value.map { |v| convert_decimal_strings(v) }
  when String
    value.match?(/\A-?\d+(\.\d+)?\z/) ? BigDecimal(value) : value
  else value
  end
end

.extract_decls_from_ruby(res) ⇒ Object



75
76
77
78
# File 'lib/kumi/dev/golden_runtime.rb', line 75

def extract_decls_from_ruby(res)
  code = res.state[:ruby_codegen_files]["codegen.rb"]
  code.scan(/def self\._(\w+)\(/).flatten
end

.normalize(value) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/kumi/dev/golden_runtime.rb', line 128

def normalize(value)
  case value
  when Hash  then value.keys.sort_by(&:to_s).to_h { |k| [k.to_s, normalize(value[k])] }
  when Array then value.map { |v| normalize(v) }
  when BigDecimal then value.to_f
  # Ruby distinguishes 0.0 (Float) from 0 (Integer); JS does not, so an
  # integer-valued result serializes as "0.0" in Ruby and "0" from the JS
  # runner. Render every number as a Float so the two compare equal and the
  # snapshot is stable.
  when Numeric
    f = value.to_f
    f.zero? ? 0.0 : f # collapse -0.0 to 0.0 (JS prints both as 0)
  else value
  end
end

.output_decl_names(res) ⇒ Object



65
66
67
68
69
70
71
72
73
# File 'lib/kumi/dev/golden_runtime.rb', line 65

def output_decl_names(res)
  # value declarations only (the schema's outputs).
  schema = res.state[:nast_module] || res.state[:snast_module]
  if schema.respond_to?(:decls)
    schema.decls.select { |_, d| d.respond_to?(:kind) ? d.kind == :value : true }.keys.map(&:to_s)
  else
    res.state[:ruby_codegen_files] ? extract_decls_from_ruby(res) : []
  end
end

.run_js(res, input, decls, dir) ⇒ Object



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/kumi/dev/golden_runtime.rb', line 97

def run_js(res, input, decls, dir)
  code = res.state[:javascript_codegen_files]&.fetch("codegen.mjs", nil)
  raise "no javascript codegen" unless code
  raise "JS runner missing at #{JS_RUNNER}" unless File.exist?(JS_RUNNER)

  Dir.mktmpdir("golden_runtime_js") do |tmp|
    mod_path = File.join(tmp, "codegen.mjs")
    in_path  = File.join(tmp, "input.json")
    File.write(mod_path, code)
    File.write(in_path, JSON.generate(input))
    out, err, status = Open3.capture3("node", JS_RUNNER, mod_path, in_path, decls.join(","))
    raise "JS runner failed for #{dir}:\n#{err}" unless status.success?

    JSON.parse(out)
  end
end

.run_ruby(res, input, decls) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/kumi/dev/golden_runtime.rb', line 80

def run_ruby(res, input, decls)
  code = res.state[:ruby_codegen_files]&.fetch("codegen.rb", nil)
  raise "no ruby codegen" unless code

  # Ensure the wrapper class (defined alongside Kumi::Schema) is loaded
  # before we reference it below.
  Kumi.const_get(:Schema)

  input = convert_decimal_strings(input)
  module_name = code.match(/module (Kumi::Compiled::\S+)/)[1]
  # eval defines the module if not already present; const_get either way.
  eval(code) unless Object.const_defined?(module_name) # rubocop:disable Security/Eval
  mod = Object.const_get(module_name)
  instance = Kumi::CompiledSchemaWrapper.new(mod, input)
  decls.to_h { |name| [name, instance[name.to_sym]] }
end

.schema_has_imports?(schema_path) ⇒ Boolean

Returns:

  • (Boolean)


154
155
156
# File 'lib/kumi/dev/golden_runtime.rb', line 154

def schema_has_imports?(schema_path)
  File.exist?(schema_path) && File.read(schema_path).match?(/\bimport\s+/)
end

.schema_uses_decimal?(schema_path) ⇒ Boolean

Returns:

  • (Boolean)


158
159
160
# File 'lib/kumi/dev/golden_runtime.rb', line 158

def schema_uses_decimal?(schema_path)
  File.exist?(schema_path) && File.read(schema_path).match?(/\bto_decimal\b|\bdecimal\b/)
end

.snapshot(schema_path) ⇒ Object

Returns a canonical JSON string of the schema’s outputs, or nil if the schema dir has no input.json (text-only golden). Raises on a Ruby/JS mismatch or an execution error so verify surfaces it loudly.



26
27
28
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
# File 'lib/kumi/dev/golden_runtime.rb', line 26

def snapshot(schema_path)
  dir = File.dirname(schema_path)
  input_file = File.join(dir, "input.json")
  return nil unless File.exist?(input_file)

  input = JSON.parse(File.read(input_file))
  schema, = Kumi::Frontends.load(path: schema_path)
  res = Kumi::Analyzer.analyze!(schema, side_tables: true)

  decls = output_decl_names(res)
  ruby_out = run_ruby(res, input, decls)

  # JS parity is asserted EXACTLY (Kumi's bit-identical guarantee), except:
  #  - imports: JS needs generated shared modules (matches v1's skip).
  #  - decimals: `to_decimal` uses Ruby BigDecimal (exact) but JS has no
  #    decimal type and computes in float — a real, documented semantic gap,
  #    not a regression. We still snapshot the Ruby outputs for regression
  #    detection, but don't require Ruby == JS for these.
  parity_skipped =
    if schema_has_imports?(schema_path)
      "imports"
    elsif schema_uses_decimal?(schema_path)
      "decimal (Ruby BigDecimal vs JS float)"
    end

  unless parity_skipped
    js_out = run_js(res, input, decls, dir)
    assert_parity!(ruby_out, js_out, schema_path)
  end

  snapshot_with_note(ruby_out, parity_skipped)
end

.snapshot_with_note(ruby_out, parity_skipped) ⇒ Object



59
60
61
62
63
# File 'lib/kumi/dev/golden_runtime.rb', line 59

def snapshot_with_note(ruby_out, parity_skipped)
  body = normalize(ruby_out)
  body = { "__ruby_js_parity_skipped" => parity_skipped, "outputs" => body } if parity_skipped
  JSON.pretty_generate(body)
end