kumi 0.0.13 → 0.0.15

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/BACKLOG.md +34 -0
  4. data/CHANGELOG.md +33 -0
  5. data/CLAUDE.md +4 -6
  6. data/README.md +0 -45
  7. data/config/functions.yaml +352 -0
  8. data/docs/dev/analyzer-debug.md +52 -0
  9. data/docs/dev/parse-command.md +64 -0
  10. data/docs/dev/vm-profiling.md +95 -0
  11. data/docs/features/README.md +0 -7
  12. data/docs/functions/analyzer_integration.md +199 -0
  13. data/docs/functions/signatures.md +171 -0
  14. data/examples/hash_objects_demo.rb +138 -0
  15. data/golden/array_operations/schema.kumi +17 -0
  16. data/golden/cascade_logic/schema.kumi +16 -0
  17. data/golden/mixed_nesting/schema.kumi +42 -0
  18. data/golden/simple_math/schema.kumi +10 -0
  19. data/lib/kumi/analyzer.rb +76 -22
  20. data/lib/kumi/compiler.rb +6 -5
  21. data/lib/kumi/core/analyzer/checkpoint.rb +72 -0
  22. data/lib/kumi/core/analyzer/debug.rb +167 -0
  23. data/lib/kumi/core/analyzer/passes/broadcast_detector.rb +1 -3
  24. data/lib/kumi/core/analyzer/passes/function_signature_pass.rb +199 -0
  25. data/lib/kumi/core/analyzer/passes/ir_dependency_pass.rb +67 -0
  26. data/lib/kumi/core/analyzer/passes/load_input_cse.rb +120 -0
  27. data/lib/kumi/core/analyzer/passes/lower_to_ir_pass.rb +72 -157
  28. data/lib/kumi/core/analyzer/passes/toposorter.rb +40 -36
  29. data/lib/kumi/core/analyzer/state_serde.rb +64 -0
  30. data/lib/kumi/core/analyzer/structs/access_plan.rb +12 -10
  31. data/lib/kumi/core/compiler/access_planner.rb +3 -2
  32. data/lib/kumi/core/function_registry/collection_functions.rb +3 -1
  33. data/lib/kumi/core/functions/dimension.rb +98 -0
  34. data/lib/kumi/core/functions/dtypes.rb +20 -0
  35. data/lib/kumi/core/functions/errors.rb +11 -0
  36. data/lib/kumi/core/functions/kernel_adapter.rb +45 -0
  37. data/lib/kumi/core/functions/loader.rb +119 -0
  38. data/lib/kumi/core/functions/registry_v2.rb +68 -0
  39. data/lib/kumi/core/functions/shape.rb +70 -0
  40. data/lib/kumi/core/functions/signature.rb +122 -0
  41. data/lib/kumi/core/functions/signature_parser.rb +86 -0
  42. data/lib/kumi/core/functions/signature_resolver.rb +272 -0
  43. data/lib/kumi/core/ir/execution_engine/interpreter.rb +110 -7
  44. data/lib/kumi/core/ir/execution_engine/profiler.rb +330 -0
  45. data/lib/kumi/core/ir/execution_engine.rb +6 -15
  46. data/lib/kumi/dev/ir.rb +75 -0
  47. data/lib/kumi/dev/parse.rb +105 -0
  48. data/lib/kumi/dev/profile_aggregator.rb +301 -0
  49. data/lib/kumi/dev/profile_runner.rb +199 -0
  50. data/lib/kumi/dev/runner.rb +85 -0
  51. data/lib/kumi/dev.rb +14 -0
  52. data/lib/kumi/frontends/ruby.rb +28 -0
  53. data/lib/kumi/frontends/text.rb +46 -0
  54. data/lib/kumi/frontends.rb +29 -0
  55. data/lib/kumi/kernels/ruby/aggregate_core.rb +105 -0
  56. data/lib/kumi/kernels/ruby/datetime_scalar.rb +21 -0
  57. data/lib/kumi/kernels/ruby/mask_scalar.rb +15 -0
  58. data/lib/kumi/kernels/ruby/scalar_core.rb +63 -0
  59. data/lib/kumi/kernels/ruby/string_scalar.rb +19 -0
  60. data/lib/kumi/kernels/ruby/vector_struct.rb +39 -0
  61. data/lib/kumi/runtime/executable.rb +108 -45
  62. data/lib/kumi/schema.rb +12 -6
  63. data/lib/kumi/support/diff.rb +22 -0
  64. data/lib/kumi/support/ir_render.rb +61 -0
  65. data/lib/kumi/version.rb +1 -1
  66. data/lib/kumi.rb +3 -0
  67. data/performance_results.txt +63 -0
  68. data/scripts/test_mixed_nesting_performance.rb +206 -0
  69. metadata +50 -6
  70. data/docs/features/analysis-cascade-mutual-exclusion.md +0 -89
  71. data/docs/features/javascript-transpiler.md +0 -148
  72. data/lib/kumi/js.rb +0 -23
  73. data/lib/kumi/support/ir_dump.rb +0 -491
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require "set"
7
+
8
+ module Kumi
9
+ module Core
10
+ module IR
11
+ module ExecutionEngine
12
+ module Profiler
13
+ class << self
14
+ def enabled? = ENV["KUMI_PROFILE"] == "1"
15
+ def ops_enabled? = ENV.fetch("KUMI_PROFILE_OPS", "1") == "1"
16
+ def sample_rate = (ENV["KUMI_PROFILE_SAMPLE"]&.to_i || 1)
17
+ def persistent? = ENV["KUMI_PROFILE_PERSISTENT"] == "1"
18
+
19
+ def set_schema_name(name)
20
+ @schema_name = name
21
+
22
+ # Ensure profiler is initialized in persistent mode
23
+ unless @initialized
24
+ @events = []
25
+ @meta = {}
26
+ @file = ENV["KUMI_PROFILE_FILE"] || "tmp/profile.jsonl"
27
+ @run_id ||= 1
28
+ @op_seq ||= 0
29
+ @aggregated_stats ||= Hash.new { |h, k| h[k] = { count: 0, total_ms: 0.0, total_cpu_ms: 0.0, rows: 0, runs: Set.new } }
30
+
31
+ # Truncate file if needed
32
+ if ENV["KUMI_PROFILE_TRUNCATE"] == "1" && !@persistent_initialized
33
+ FileUtils.mkdir_p(File.dirname(@file))
34
+ File.write(@file, "")
35
+ @aggregated_stats.clear
36
+ @persistent_initialized = true
37
+ end
38
+
39
+ @initialized = true
40
+ end
41
+ end
42
+
43
+ def reset!(meta: {})
44
+ set_schema_name(meta[:schema_name]) if meta[:schema_name]
45
+ return unless enabled?
46
+
47
+ # In persistent mode, don't reset aggregated stats or increment run_id
48
+ # This allows profiling across multiple schema creations
49
+ if persistent?
50
+ @events = []
51
+ @meta = (@meta || {}).merge(meta)
52
+ @schema_name = meta[:schema_name] if meta[:schema_name]
53
+ @file = ENV["KUMI_PROFILE_FILE"] || "tmp/profile.jsonl"
54
+ @run_id ||= 1
55
+ @op_seq ||= 0
56
+ @aggregated_stats ||= Hash.new { |h, k| h[k] = { count: 0, total_ms: 0.0, total_cpu_ms: 0.0, rows: 0, runs: Set.new } }
57
+
58
+ # Only truncate on very first reset in persistent mode
59
+ if ENV["KUMI_PROFILE_TRUNCATE"] == "1" && !@persistent_initialized
60
+ FileUtils.mkdir_p(File.dirname(@file))
61
+ File.write(@file, "")
62
+ @aggregated_stats.clear
63
+ @persistent_initialized = true
64
+ end
65
+ else
66
+ # Original behavior: full reset each time
67
+ @events = []
68
+ @meta = meta
69
+ @schema_name = meta[:schema_name]
70
+ @file = ENV["KUMI_PROFILE_FILE"] || "tmp/profile.jsonl"
71
+ @run_id = (@run_id || 0) + 1
72
+ @op_seq = 0
73
+ @aggregated_stats = (@aggregated_stats || Hash.new { |h, k| h[k] = { count: 0, total_ms: 0.0, total_cpu_ms: 0.0, rows: 0, runs: Set.new } })
74
+
75
+ if ENV["KUMI_PROFILE_TRUNCATE"] == "1"
76
+ FileUtils.mkdir_p(File.dirname(@file))
77
+ File.write(@file, "")
78
+ @aggregated_stats.clear
79
+ end
80
+ end
81
+ end
82
+
83
+ # monotonic start time
84
+ def t0
85
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ end
87
+
88
+ # CPU time start (process + thread)
89
+ def cpu_t0
90
+ Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
91
+ end
92
+
93
+ # Phase timing for coarse-grained operations
94
+ def phase(name, tags = {})
95
+ return yield unless enabled?
96
+ p0 = t0; c0 = cpu_t0
97
+ result = yield
98
+ wall_ms = (t0 - p0) * 1000.0
99
+ cpu_ms = (cpu_t0 - c0) * 1000.0
100
+ stream({
101
+ ts: Time.now.utc.iso8601(3),
102
+ kind: "phase",
103
+ name: name,
104
+ wall_ms: wall_ms.round(3),
105
+ cpu_ms: cpu_ms.round(3),
106
+ tags: tags,
107
+ run: @run_id
108
+ })
109
+ result
110
+ end
111
+
112
+ # Memory snapshot with GC statistics
113
+ def memory_snapshot(label, extra: {})
114
+ return unless enabled?
115
+ s = GC.stat
116
+ stream({
117
+ ts: Time.now.utc.iso8601(3),
118
+ kind: "mem",
119
+ label: label,
120
+ heap_live: s[:heap_live_slots],
121
+ old_objects: s[:old_objects],
122
+ minor_gc: s[:minor_gc_count],
123
+ major_gc: s[:major_gc_count],
124
+ rss_mb: read_rss_mb,
125
+ run: @run_id,
126
+ **extra
127
+ })
128
+ end
129
+
130
+ def read_rss_mb
131
+ ((File.read("/proc/#{$$}/status")[/VmRSS:\s+(\d+)\skB/, 1].to_i) / 1024.0).round(2)
132
+ rescue
133
+ nil
134
+ end
135
+
136
+ # Per-op record with both wall time and CPU time (with sampling support)
137
+ def record!(decl:, idx:, tag:, op:, t0:, cpu_t0: nil, rows: nil, note: nil)
138
+ return unless enabled? && ops_enabled?
139
+ @op_seq += 1
140
+ return unless sample_rate <= 1 || (@op_seq % sample_rate).zero?
141
+
142
+ wall_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000.0)
143
+ cpu_ms = cpu_t0 ? ((Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu_t0) * 1000.0) : wall_ms
144
+
145
+ ev = {
146
+ ts: Time.now.utc.iso8601(3),
147
+ run: @run_id,
148
+ schema: @schema_name, # schema identifier for multi-schema differentiation
149
+ decl: decl, # decl name (string/symbol)
150
+ i: idx, # op index
151
+ tag: tag, # op tag (symbol)
152
+ wall_ms: wall_ms.round(4),
153
+ cpu_ms: cpu_ms.round(4),
154
+ rows: rows,
155
+ note: note,
156
+ key: op_key(decl, idx, tag, op), # stable key for grep/diff
157
+ attrs: compact_attrs(op.attrs)
158
+ }
159
+
160
+ # Aggregate stats for multi-run averaging
161
+ op_key = "#{decl}@#{idx}:#{tag}"
162
+ agg = @aggregated_stats[op_key]
163
+ agg[:count] += 1
164
+ agg[:total_ms] += wall_ms
165
+ agg[:total_cpu_ms] += cpu_ms
166
+ agg[:rows] += (rows || 0)
167
+ agg[:runs] << @run_id
168
+ agg[:decl] = decl
169
+ agg[:tag] = tag
170
+ agg[:idx] = idx
171
+ agg[:note] = note if note
172
+
173
+ (@events ||= []) << ev
174
+ stream(ev) if ENV["KUMI_PROFILE_STREAM"] == "1"
175
+ ev
176
+ end
177
+
178
+ def summary(top: 20)
179
+ return {} unless enabled?
180
+
181
+ # Current run summary (legacy format)
182
+ current_agg = Hash.new { |h, k| h[k] = { count: 0, ms: 0.0, rows: 0 } }
183
+ (@events || []).each do |e|
184
+ k = [e[:decl], e[:tag]]
185
+ a = current_agg[k]
186
+ a[:count] += 1
187
+ a[:ms] += (e[:wall_ms] || e[:ms] || 0)
188
+ a[:rows] += (e[:rows] || 0)
189
+ end
190
+ current_ranked = current_agg.map { |(decl, tag), v|
191
+ { decl: decl, tag: tag, count: v[:count], ms: v[:ms].round(3), rows: v[:rows],
192
+ rps: v[:rows] > 0 ? (v[:rows] / v[:ms]).round(1) : nil }
193
+ }.sort_by { |h| -h[:ms] }.first(top)
194
+
195
+ { meta: @meta || {}, top: current_ranked,
196
+ total_ms: ((@events || []).sum { |e| e[:wall_ms] || e[:ms] || 0 }).round(3),
197
+ op_count: (@events || []).size,
198
+ run_id: @run_id }
199
+ end
200
+
201
+ # Multi-run averaged analysis
202
+ def averaged_analysis(top: 20)
203
+ return {} unless enabled? && @aggregated_stats&.any?
204
+
205
+ # Convert aggregated stats to averaged metrics
206
+ averaged = @aggregated_stats.map do |op_key, stats|
207
+ num_runs = stats[:runs].size
208
+ avg_wall_ms = stats[:total_ms] / stats[:count]
209
+ avg_cpu_ms = stats[:total_cpu_ms] / stats[:count]
210
+ total_wall_ms = stats[:total_ms]
211
+ total_cpu_ms = stats[:total_cpu_ms]
212
+
213
+ {
214
+ op_key: op_key,
215
+ decl: stats[:decl],
216
+ idx: stats[:idx],
217
+ tag: stats[:tag],
218
+ runs: num_runs,
219
+ total_calls: stats[:count],
220
+ calls_per_run: stats[:count] / num_runs.to_f,
221
+ avg_wall_ms: avg_wall_ms.round(4),
222
+ avg_cpu_ms: avg_cpu_ms.round(4),
223
+ total_wall_ms: total_wall_ms.round(3),
224
+ total_cpu_ms: total_cpu_ms.round(3),
225
+ cpu_efficiency: total_wall_ms > 0 ? (total_cpu_ms / total_wall_ms * 100).round(1) : 100,
226
+ rows_total: stats[:rows],
227
+ note: stats[:note]
228
+ }
229
+ end.sort_by { |s| -s[:total_wall_ms] }.first(top)
230
+
231
+ {
232
+ meta: @meta || {},
233
+ total_runs: (@aggregated_stats.values.map { |s| s[:runs].size }.max || 0),
234
+ averaged_ops: averaged,
235
+ total_operations: @aggregated_stats.size
236
+ }
237
+ end
238
+
239
+ # Identify potential cache overhead operations
240
+ def cache_overhead_analysis
241
+ return {} unless enabled? && @aggregated_stats&.any?
242
+
243
+ # Look for operations that might be cache-related
244
+ cache_ops = @aggregated_stats.select do |op_key, stats|
245
+ op_key.include?("ref") || op_key.include?("load_input") || stats[:note]&.include?("cache")
246
+ end
247
+
248
+ cache_analysis = cache_ops.map do |op_key, stats|
249
+ num_runs = stats[:runs].size
250
+ avg_wall_ms = stats[:total_ms] / stats[:count]
251
+
252
+ {
253
+ op_key: op_key,
254
+ decl: stats[:decl],
255
+ tag: stats[:tag],
256
+ avg_time_ms: avg_wall_ms.round(4),
257
+ total_time_ms: stats[:total_ms].round(3),
258
+ call_count: stats[:count],
259
+ overhead_per_call: avg_wall_ms.round(6)
260
+ }
261
+ end.sort_by { |s| -s[:total_time_ms] }
262
+
263
+ {
264
+ cache_operations: cache_analysis,
265
+ total_cache_time: cache_analysis.sum { |op| op[:total_time_ms] }.round(3)
266
+ }
267
+ end
268
+
269
+ def emit_summary!
270
+ return unless enabled?
271
+ stream({ ts: Time.now.utc.iso8601(3), kind: "summary", data: summary })
272
+ end
273
+
274
+ def init_persistent!
275
+ return unless enabled? && persistent?
276
+ @persistent_initialized = false
277
+ reset!
278
+ end
279
+
280
+ def finalize!
281
+ return unless enabled?
282
+
283
+ # Emit final aggregated summary
284
+ if @aggregated_stats&.any?
285
+ stream({
286
+ ts: Time.now.utc.iso8601(3),
287
+ kind: "final_summary",
288
+ data: averaged_analysis
289
+ })
290
+ end
291
+
292
+ # Emit cache analysis if available
293
+ cache_analysis = cache_overhead_analysis
294
+ if cache_analysis[:cache_operations]&.any?
295
+ stream({
296
+ ts: Time.now.utc.iso8601(3),
297
+ kind: "cache_analysis",
298
+ data: cache_analysis
299
+ })
300
+ end
301
+ end
302
+
303
+ # Stable textual key for "match ops one by one"
304
+ def op_key(decl, idx, tag, op)
305
+ attrs = compact_attrs(op.attrs)
306
+ args = op.args
307
+ "#{decl}@#{idx}:#{tag}|#{attrs.keys.sort_by(&:to_s).map { |k| "#{k}=#{attrs[k].inspect}" }.join(",")}|args=#{args.inspect}"
308
+ end
309
+
310
+ def compact_attrs(h)
311
+ return {} unless h
312
+ h.transform_values do |v|
313
+ case v
314
+ when Array, Hash, Symbol, String, Numeric, TrueClass, FalseClass, NilClass then v
315
+ else v.to_s
316
+ end
317
+ end
318
+ end
319
+
320
+ def stream(obj)
321
+ return unless @file
322
+ FileUtils.mkdir_p(File.dirname(@file))
323
+ File.open(@file, "a") { |f| f.puts(obj.to_json) }
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -43,15 +43,15 @@ module Kumi
43
43
  module ExecutionEngine
44
44
  def self.run(ir_module, ctx, accessors:, registry:)
45
45
  # Use persistent accessor cache if available, otherwise create temporary one
46
- if ctx[:accessor_cache]
46
+ memoized_accessors = Dev::Profiler.phase("engine.memoization") do
47
47
  # Include input data in cache key to avoid cross-context pollution
48
48
  input_key = ctx[:input]&.hash || ctx["input"]&.hash || 0
49
- memoized_accessors = add_persistent_memoization(accessors, ctx[:accessor_cache], input_key)
50
- else
51
- memoized_accessors = add_temporary_memoization(accessors)
49
+ add_persistent_memoization(accessors, ctx[:accessor_cache], input_key)
50
+ end
51
+
52
+ Dev::Profiler.phase("engine.interpreter") do
53
+ Interpreter.run(ir_module, ctx, accessors: memoized_accessors, registry: registry)
52
54
  end
53
-
54
- Interpreter.run(ir_module, ctx, accessors: memoized_accessors, registry: registry)
55
55
  end
56
56
 
57
57
  private
@@ -64,15 +64,6 @@ module Kumi
64
64
  end]
65
65
  end.to_h
66
66
  end
67
-
68
- def self.add_temporary_memoization(accessors)
69
- cache = {}
70
- accessors.map do |plan_id, accessor_fn|
71
- [plan_id, lambda do |input_data|
72
- cache[plan_id] ||= accessor_fn.call(input_data)
73
- end]
74
- end.to_h
75
- end
76
67
  end
77
68
  end
78
69
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kumi
6
+ module Dev
7
+ module IR
8
+ module_function
9
+
10
+ def to_text(ir_module)
11
+ raise "nil IR" unless ir_module
12
+
13
+ lines = []
14
+ lines << "IR Module"
15
+ lines << "decls: #{ir_module.decls.size}"
16
+
17
+ ir_module.decls.each_with_index do |decl, i|
18
+ lines << "decl[#{i}] #{decl.kind}:#{decl.name} shape=#{decl.shape} ops=#{decl.ops.size}"
19
+
20
+ decl.ops.each_with_index do |op, j|
21
+ # Sort attribute keys for deterministic output
22
+ sorted_attrs = op.attrs.keys.sort.map { |k| "#{k}=#{format_value(op.attrs[k])}" }.join(" ")
23
+ args_str = op.args.inspect
24
+ lines << " #{j}: #{op.tag} #{sorted_attrs} #{args_str}".rstrip
25
+ end
26
+ end
27
+
28
+ lines.join("\n") + "\n"
29
+ end
30
+
31
+ private
32
+
33
+ def self.format_value(val)
34
+ case val
35
+ when true, false
36
+ val.to_s
37
+ when Symbol
38
+ ":#{val}"
39
+ when Array
40
+ val.inspect
41
+ else
42
+ val.to_s
43
+ end
44
+ end
45
+
46
+ def to_json(ir_module, pretty: true)
47
+ raise "nil IR" unless ir_module
48
+
49
+ data = {
50
+ inputs: ir_module.inputs,
51
+ decls: ir_module.decls.map do |decl|
52
+ {
53
+ name: decl.name,
54
+ kind: decl.kind,
55
+ shape: decl.shape,
56
+ ops: decl.ops.map do |op|
57
+ {
58
+ tag: op.tag,
59
+ attrs: op.attrs,
60
+ args: op.args
61
+ }
62
+ end
63
+ }
64
+ end
65
+ }
66
+
67
+ if pretty
68
+ JSON.pretty_generate(data)
69
+ else
70
+ JSON.generate(data)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Kumi
6
+ module Dev
7
+ module Parse
8
+ module_function
9
+
10
+ def run(schema_path, opts = {})
11
+ # Load schema via text frontend
12
+ begin
13
+ schema, _inputs = Kumi::Frontends::Text.load(path: schema_path)
14
+ rescue LoadError => e
15
+ puts "Error: kumi-parser gem not available. Install: gem install kumi-parser"
16
+ return false
17
+ rescue StandardError => e
18
+ puts "Parse error: #{e.message}"
19
+ return false
20
+ end
21
+
22
+ # Run analyzer
23
+ runner_opts = opts.slice(:trace, :snap, :snap_dir, :resume_from, :resume_at, :stop_after)
24
+ res = Dev::Runner.run(schema, runner_opts)
25
+
26
+ unless res.ok?
27
+ puts "Analysis errors:"
28
+ res.errors.each { |err| puts " #{err}" }
29
+ return false
30
+ end
31
+
32
+ unless res.ir
33
+ puts "Error: No IR generated"
34
+ return false
35
+ end
36
+
37
+ # Report trace file if enabled
38
+ if opts[:trace] && res.respond_to?(:trace_file)
39
+ puts "Trace written to: #{res.trace_file}"
40
+ end
41
+
42
+ # Determine file extension and renderer
43
+ extension = opts[:json] ? "json" : "txt"
44
+ golden_path = File.join(File.dirname(schema_path), "expected", "ir.#{extension}")
45
+
46
+ # Render IR
47
+ rendered = if opts[:json]
48
+ Dev::IR.to_json(res.ir, pretty: true)
49
+ else
50
+ Dev::IR.to_text(res.ir)
51
+ end
52
+
53
+ # Handle write mode
54
+ if opts[:write]
55
+ FileUtils.mkdir_p(File.dirname(golden_path))
56
+ File.write(golden_path, rendered)
57
+ puts "Wrote: #{golden_path}"
58
+ return true
59
+ end
60
+
61
+ # Handle update mode (write only if different)
62
+ if opts[:update]
63
+ if File.exist?(golden_path) && File.read(golden_path) == rendered
64
+ puts "No changes (#{golden_path})"
65
+ return true
66
+ else
67
+ FileUtils.mkdir_p(File.dirname(golden_path))
68
+ File.write(golden_path, rendered)
69
+ puts "Updated: #{golden_path}"
70
+ return true
71
+ end
72
+ end
73
+
74
+ # Handle no-diff mode
75
+ if opts[:no_diff]
76
+ puts rendered
77
+ return true
78
+ end
79
+
80
+ # Default: diff mode (same as write but show diff instead)
81
+ if File.exist?(golden_path)
82
+ # Use diff directly with the golden file path
83
+ require "tempfile"
84
+ Tempfile.create(["actual", File.extname(golden_path)]) do |actual_file|
85
+ actual_file.write(rendered)
86
+ actual_file.flush
87
+
88
+ result = `diff -u --label=expected --label=actual #{golden_path} #{actual_file.path}`
89
+ if result.empty?
90
+ puts "No changes (#{golden_path})"
91
+ return true
92
+ else
93
+ puts result.chomp
94
+ return false
95
+ end
96
+ end
97
+ else
98
+ # No golden file exists, just print the output
99
+ puts rendered
100
+ return true
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end