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.("support/kumi_runner.mjs", __dir__)
Class Method Summary collapse
- .assert_parity!(ruby_out, js_out, schema_path) ⇒ Object
-
.canonical(value) ⇒ Object
Stable, normalized JSON: BigDecimals to floats, keys sorted, so snapshots are deterministic and Ruby/JS compare equal.
- .convert_decimal_strings(value) ⇒ Object
- .extract_decls_from_ruby(res) ⇒ Object
- .normalize(value) ⇒ Object
- .output_decl_names(res) ⇒ Object
- .run_js(res, input, decls, dir) ⇒ Object
- .run_ruby(res, input, decls) ⇒ Object
- .schema_has_imports?(schema_path) ⇒ Boolean
- .schema_uses_decimal?(schema_path) ⇒ Boolean
-
.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).
- .snapshot_with_note(ruby_out, parity_skipped) ⇒ Object
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
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
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 |