lumitrace 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2ba7add22897e99449be04384c54d69bc475240840e2d229f73b90a5f289c0a0
4
+ data.tar.gz: 259751a540b06b2f790bef892f14191b1daaa8341ec5a484b8e91affe46120fa
5
+ SHA512:
6
+ metadata.gz: cf8fc986e2d8ff10480bbbbb8ce57eac21cd9d72ba94a83604ffdba7e4cdccd8b6cb9a8e0e52b6413f5bf098a4d8d4d90e60c8cd70038d52bb0826d83b26c56a
7
+ data.tar.gz: e3752f0b9f8249345443352898c69a138e4cdb35f5ff3257c5a52b7bd9665b0ac48e6c0aca16575a6577b67838d47d709c453238ce35fc5ba6d8506c1a0420b2
data/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # Lumitrace
2
+
3
+ Lumitrace instruments Ruby source code at load time, records expression results, and renders an HTML view that overlays recorded values on your code. It is designed for quick, local “what happened here?” inspection during test runs or scripts.
4
+
5
+ ## How It Works
6
+
7
+ Lumitrace hooks `RubyVM::InstructionSequence.translate` (when available) to rewrite files at require-time. It records expression results and renders an HTML view that shows them inline. Only the last N values per expression are kept to avoid huge output.
8
+
9
+ ## Usage
10
+
11
+ ### CLI
12
+
13
+ Run a script and emit HTML (default output: `lumitrace_recorded.html`):
14
+
15
+ ```bash
16
+ ruby exe/lumitrace path/to/entry.rb
17
+ ```
18
+
19
+ Limit the number of recorded values per expression (defaults to 3):
20
+
21
+ ```bash
22
+ LUMITRACE_VALUES_MAX=5 ruby exe/lumitrace path/to/entry.rb
23
+ ```
24
+
25
+ Write JSON output explicitly:
26
+
27
+ ```bash
28
+ ruby exe/lumitrace path/to/entry.rb --json
29
+ ruby exe/lumitrace path/to/entry.rb --json out/lumitrace_recorded.json
30
+ ```
31
+
32
+ Restrict to specific line ranges:
33
+
34
+ ```bash
35
+ ruby exe/lumitrace path/to/entry.rb --range path/to/entry.rb:10-20,30-35
36
+ ```
37
+
38
+ ### Library
39
+
40
+ Enable instrumentation and HTML output at exit:
41
+
42
+ ```ruby
43
+ require "lumitrace"
44
+ Lumitrace.enable!
45
+ ```
46
+
47
+ Enable only for diff ranges (current file):
48
+
49
+ ```ruby
50
+ require "lumitrace/enable_git_diff"
51
+ ```
52
+
53
+ If you want to enable via a single require:
54
+
55
+ ```ruby
56
+ require "lumitrace/enable"
57
+ ```
58
+
59
+ ## Output
60
+
61
+ - HTML: `lumitrace_recorded.html` by default, override with `LUMITRACE_HTML_OUT`.
62
+ - JSON: written only when `--json` (CLI) or `LUMITRACE_JSON_OUT` (library) is provided. Default filename is `lumitrace_recorded.json`.
63
+
64
+ ## Environment Variables
65
+
66
+ - `LUMITRACE_VALUES_MAX`: default max values per expression (default 3 if unset).
67
+ - `LUMITRACE_ROOT`: root directory used to decide which files are instrumented.
68
+ - `LUMITRACE_HTML_OUT`: override HTML output path.
69
+ - `LUMITRACE_JSON_OUT`: if set, writes JSON to this path at exit.
70
+ - `LUMITRACE_GIT_DIFF=working|staged|base:REV|range:SPEC`: diff source for `enable_git_diff`.
71
+ - `LUMITRACE_GIT_DIFF_CONTEXT=N`: expand diff hunks by +/-N lines (default 3).
72
+ - `LUMITRACE_GIT_CMD`: git executable override (default `git`).
73
+
74
+ ## Notes And Limitations
75
+
76
+ - Requires `RubyVM::InstructionSequence.translate` support.
77
+ - Very large projects or hot loops can still generate large HTML; use `LUMITRACE_VALUES_MAX`.
78
+ - Instrumentation changes evaluation order for debugging, not for production.
79
+
80
+ ## Development
81
+
82
+ Install dependencies:
83
+
84
+ ```bash
85
+ bundle install
86
+ ```
87
+
88
+ Run the CLI locally:
89
+
90
+ ```bash
91
+ ruby exe/lumitrace path/to/entry.rb
92
+ ```
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << "test"
8
+ t.test_files = FileList["test/**/*_test.rb", "test/**/test_*.rb"]
9
+ t.verbose = false
10
+ end
11
+
12
+ task default: :test
data/exe/lumitrace ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require_relative "../lib/lumitrace"
7
+
8
+ html_out = nil
9
+ max_values = nil
10
+ range_specs = []
11
+ json_out = nil
12
+
13
+ emit_html = true
14
+
15
+ parser = OptionParser.new do |opts|
16
+ opts.banner = "usage: lumitrace FILE [options]"
17
+ opts.on("--html PATH", "Write HTML output to PATH") do |v|
18
+ emit_html = true
19
+ html_out = v
20
+ end
21
+ opts.on("--json [PATH]", "Write JSON output (default: lumitrace_recorded.json)") do |v|
22
+ json_out = v || ""
23
+ end
24
+ opts.on("--max N", Integer, "Max values per expression") do |v|
25
+ max_values = v
26
+ end
27
+ opts.on("--range SPEC", "Restrict instrumentation per file: FILE:1-5,10-12") do |v|
28
+ range_specs << v
29
+ end
30
+ opts.on("-h", "--help", "Show help") do
31
+ puts opts
32
+ exit 0
33
+ end
34
+ end
35
+
36
+ args = parser.parse(ARGV)
37
+ path = args.shift or abort(parser.to_s)
38
+
39
+ env_max = ENV["LUMITRACE_VALUES_MAX"]
40
+ max_values = env_max if max_values.nil? && env_max && !env_max.empty?
41
+
42
+ ranges_by_file = nil
43
+ if range_specs.any?
44
+ ranges_by_file = Hash.new { |h, k| h[k] = [] }
45
+ range_specs.each do |spec|
46
+ file_part, range_part = spec.split(":", 2)
47
+ if file_part.nil? || file_part.strip.empty?
48
+ abort "invalid --range (expected FILE or FILE:1-5,10-12): #{spec}"
49
+ end
50
+
51
+ file = File.expand_path(file_part)
52
+ if range_part.nil? || range_part.strip.empty?
53
+ ranges_by_file[file] = []
54
+ next
55
+ end
56
+
57
+ range_part.split(",").each do |seg|
58
+ seg = seg.strip
59
+ if seg =~ /\A(\d+)-(\d+)\z/
60
+ ranges_by_file[file] << ($1.to_i..$2.to_i)
61
+ else
62
+ abort "invalid --range segment (expected N-M): #{seg}"
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ Lumitrace.enable!(max_values: max_values, ranges_by_file: ranges_by_file)
69
+ load File.expand_path(path)
70
+ events = Lumitrace::RecordInstrument.events
71
+
72
+ if json_out
73
+ json_path = json_out.empty? ? "lumitrace_recorded.json" : json_out
74
+ events_path = File.expand_path(json_path, Dir.pwd)
75
+ Lumitrace.dump!(events_path)
76
+ puts "recorded: #{events_path}"
77
+ end
78
+
79
+ if emit_html
80
+ html = Lumitrace::GenerateResultedHtml.render_all_from_events(
81
+ events,
82
+ root: File.dirname(File.expand_path(path)),
83
+ ranges_by_file: ranges_by_file
84
+ )
85
+ out_path = html_out || File.join(File.dirname(File.expand_path(path)), "lumitrace_recorded.html")
86
+ File.write(out_path, html)
87
+ puts "html: #{out_path}"
88
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lumitrace"
4
+
5
+ Lumitrace.enable!
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../lumitrace"
5
+
6
+ def lumitrace_parse_git_diff_ranges(diff_text, root)
7
+ ranges_by_file = Hash.new { |h, k| h[k] = [] }
8
+ current_file = nil
9
+
10
+ diff_text.each_line do |line|
11
+ if line.start_with?("+++ ")
12
+ path = line.sub("+++ ", "").strip
13
+ if path == "/dev/null"
14
+ current_file = nil
15
+ else
16
+ path = path.sub(%r{\A[ab]/}, "")
17
+ current_file = File.expand_path(path, root)
18
+ end
19
+ elsif line.start_with?("@@")
20
+ next unless current_file
21
+ if line =~ /\+(\d+)(?:,(\d+))?\s@@/
22
+ start_line = Regexp.last_match(1).to_i
23
+ count = Regexp.last_match(2) ? Regexp.last_match(2).to_i : 1
24
+ next if count == 0
25
+ end_line = start_line + count - 1
26
+ context = ENV.fetch("LUMITRACE_GIT_DIFF_CONTEXT", "3").to_i
27
+ context = 0 if context < 0
28
+ start_line = [start_line - context, 1].max
29
+ end_line += context
30
+ ranges_by_file[current_file] << (start_line..end_line)
31
+ end
32
+ end
33
+ end
34
+
35
+ ranges_by_file
36
+ end
37
+
38
+ def lumitrace_diff_args
39
+ mode = ENV.fetch("LUMITRACE_GIT_DIFF", "working")
40
+ case mode
41
+ when "working"
42
+ []
43
+ when "staged"
44
+ ["--cached"]
45
+ when /\Abase:(.+)\z/
46
+ [Regexp.last_match(1)]
47
+ when /\Arange:(.+)\z/
48
+ [Regexp.last_match(1)]
49
+ else
50
+ abort "invalid LUMITRACE_GIT_DIFF (working|staged|base:REV|range:SPEC): #{mode}"
51
+ end
52
+ end
53
+
54
+ def lumitrace_git_root(dir, git_cmd)
55
+ out, status = Open3.capture2(git_cmd, "-C", dir, "rev-parse", "--show-toplevel")
56
+ return dir unless status.success?
57
+ out.strip
58
+ end
59
+
60
+ def lumitrace_diff_ranges
61
+ target = File.expand_path($PROGRAM_NAME)
62
+ base_dir = File.dirname(target)
63
+ git_cmd = ENV.fetch("LUMITRACE_GIT_CMD", "git")
64
+ root = lumitrace_git_root(base_dir, git_cmd)
65
+ args = [git_cmd, "-C", base_dir, "diff", "--unified=0", "--no-color"] + lumitrace_diff_args
66
+ args += ["--", target] if File.file?(target)
67
+ stdout, status = Open3.capture2(*args)
68
+ return nil unless status.success?
69
+ ranges_by_file = lumitrace_parse_git_diff_ranges(stdout, root)
70
+ ranges_by_file.empty? ? nil : ranges_by_file
71
+ end
72
+
73
+ ranges_by_file = lumitrace_diff_ranges
74
+ Lumitrace.enable!(ranges_by_file: ranges_by_file, at_exit: true) if ranges_by_file
@@ -0,0 +1,397 @@
1
+ require "json"
2
+
3
+ module Lumitrace
4
+ module GenerateResultedHtml
5
+ def self.render(source_path, events_path, ranges: nil)
6
+ unless File.exist?(events_path)
7
+ abort "missing #{events_path}"
8
+ end
9
+ unless File.exist?(source_path)
10
+ abort "missing #{source_path}"
11
+ end
12
+
13
+ raw_events = JSON.parse(File.read(events_path))
14
+ events = normalize_events(raw_events)
15
+
16
+ src_lines = File.read(source_path).lines
17
+ ranges = normalize_ranges(ranges)
18
+
19
+ html_lines = src_lines.each_with_index.map do |line, idx|
20
+ lineno = idx + 1
21
+ next if ranges && !line_in_ranges?(lineno, ranges)
22
+ line_text = line.chomp
23
+ evs = aggregate_events_for_line(events, lineno, line_text.length)
24
+
25
+ if evs.empty?
26
+ "<span class=\"line\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
27
+ else
28
+ rendered = render_line_with_events(line_text, evs)
29
+ "<span class=\"line hit\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
30
+ end
31
+ end.compact
32
+
33
+ <<~HTML
34
+ <!doctype html>
35
+ <html>
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <title>Recorded Result View</title>
39
+ <style>
40
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
41
+ .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
42
+ .line { display: inline; padding: 2px 8px; }
43
+ .line:hover { background: #fff2c6; }
44
+ .line.hit { background: #f0ffe7; }
45
+ .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
46
+ .hint { color: #666; margin-bottom: 8px; }
47
+ .expr { position: relative; display: inline-block; padding-bottom: 1px; }
48
+ .expr.hit { }
49
+ .expr.depth-1 { --hl: #7fbf7f; }
50
+ .expr.depth-2 { --hl: #6fa8ff; }
51
+ .expr.depth-3 { --hl: #ffb347; }
52
+ .expr.depth-4 { --hl: #d78bff; }
53
+ .expr.depth-5 { --hl: #ff6f91; }
54
+ .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
55
+ .marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
56
+ .marker .tooltip {
57
+ display: none;
58
+ position: absolute;
59
+ left: 0;
60
+ top: 100%;
61
+ margin-top: 4px;
62
+ background: #2b2b2b;
63
+ color: #fff;
64
+ padding: 4px 6px;
65
+ border-radius: 4px;
66
+ font-size: 12px;
67
+ white-space: pre;
68
+ min-width: 16ch;
69
+ max-width: 90vw;
70
+ overflow-x: auto;
71
+ overflow-y: hidden;
72
+ z-index: 10;
73
+ pointer-events: auto;
74
+ }
75
+ .marker:hover .tooltip,
76
+ .marker:focus-within .tooltip,
77
+ .marker .tooltip:hover { display: block; }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div class="hint">Hover highlighted text to see recorded values.</div>
82
+ <pre class="code"><code>
83
+ #{html_lines.join("")}
84
+ </code></pre>
85
+ <script>
86
+ (function() {
87
+ document.querySelectorAll('.marker').forEach(marker => {
88
+ marker.addEventListener('mouseenter', () => {
89
+ document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
90
+ marker.closest('.expr')?.classList.add('active');
91
+ });
92
+ marker.addEventListener('mouseleave', () => {
93
+ marker.closest('.expr')?.classList.remove('active');
94
+ });
95
+ });
96
+ })();
97
+ </script>
98
+ </body>
99
+ </html>
100
+ HTML
101
+ end
102
+
103
+ def self.esc(s)
104
+ s.to_s
105
+ .gsub("&", "&amp;")
106
+ .gsub("<", "&lt;")
107
+ .gsub(">", "&gt;")
108
+ .gsub('"', "&quot;")
109
+ end
110
+
111
+ def self.format_value(v)
112
+ case v
113
+ when NilClass
114
+ "nil"
115
+ else
116
+ v.to_s
117
+ end
118
+ end
119
+
120
+ def self.render_line_with_events(line_text, events)
121
+ opens = Hash.new { |h, k| h[k] = [] }
122
+ closes = Hash.new { |h, k| h[k] = [] }
123
+
124
+ events.each do |e|
125
+ s = e[:start_col].to_i
126
+ t = e[:end_col].to_i
127
+ next if t <= s
128
+
129
+ values = e[:values]
130
+ total = e[:total]
131
+ value_text = summarize_values(values, total)
132
+ tooltip_html = esc(value_text)
133
+ depth_class = "depth-#{e[:depth]}"
134
+ open_tag = "<span class=\"expr hit #{depth_class}\">"
135
+ close_tag = "<span class=\"marker\" aria-hidden=\"true\">🔎<span class=\"tooltip\">#{tooltip_html}</span></span></span>"
136
+
137
+ len = t - s
138
+ opens[s] << { len: len, start: s, end: t, tag: open_tag }
139
+ closes[t] << { len: len, start: s, end: t, tag: close_tag }
140
+ end
141
+
142
+ positions = (opens.keys + closes.keys).uniq.sort
143
+ out = +""
144
+ cursor = 0
145
+
146
+ positions.each do |pos|
147
+ out << esc(line_text[cursor...pos]) if pos > cursor
148
+ if closes.key?(pos)
149
+ closes[pos].sort_by { |c| [-c[:start], c[:len]] }.each { |c| out << c[:tag] }
150
+ end
151
+ if opens.key?(pos)
152
+ opens[pos].sort_by { |o| -o[:end] }.each { |o| out << o[:tag] }
153
+ end
154
+ cursor = pos
155
+ end
156
+
157
+ out << esc(line_text[cursor..]) if cursor < line_text.length
158
+ out
159
+ end
160
+
161
+ def self.summarize_values(values, total = nil)
162
+ return "" if values.nil? || values.empty?
163
+ total ||= values.length
164
+ last_vals = values.last(3)
165
+ first_index = total - last_vals.length + 1
166
+ lines = []
167
+ extra = total - last_vals.length
168
+ lines << "... (+#{extra} more)" if extra > 0
169
+ last_vals.each_with_index do |v, i|
170
+ idx = first_index + i
171
+ lines << "##{idx}: #{format_value(v)}"
172
+ end
173
+ lines.join("\n")
174
+ end
175
+
176
+ def self.aggregate_events_for_line(events, lineno, line_len)
177
+ buckets = {}
178
+ spans = []
179
+
180
+ events.each do |e|
181
+ sline = e[:start_line]
182
+ eline = e[:end_line]
183
+ next if lineno < sline || lineno > eline
184
+
185
+ if sline == eline
186
+ s = e[:start_col]
187
+ t = e[:end_col]
188
+ else
189
+ if lineno == sline
190
+ s = e[:start_col]
191
+ t = line_len
192
+ elsif lineno == eline
193
+ s = 0
194
+ t = e[:end_col]
195
+ else
196
+ s = 0
197
+ t = line_len
198
+ end
199
+ end
200
+
201
+ next if t <= s
202
+ spans << { start_col: s, end_col: t }
203
+ buckets[e[:key]] = {
204
+ key: e[:key],
205
+ start_col: s,
206
+ end_col: t,
207
+ values: e[:values],
208
+ total: e[:total]
209
+ }
210
+ end
211
+
212
+ buckets.values.each do |b|
213
+ depth = spans.count { |sp| b[:start_col] >= sp[:start_col] && b[:end_col] <= sp[:end_col] }
214
+ b[:depth] = [[depth, 1].max, 5].min
215
+ end
216
+
217
+ buckets.values.sort_by { |b| b[:start_col] }
218
+ end
219
+
220
+ def self.normalize_events(events)
221
+ merged = {}
222
+ events.each do |e|
223
+ file = e["file"] || e[:file]
224
+ start_line = e["start_line"] || e[:start_line]
225
+ start_col = e["start_col"] || e[:start_col]
226
+ end_line = e["end_line"] || e[:end_line]
227
+ end_col = e["end_col"] || e[:end_col]
228
+ key = [
229
+ file,
230
+ start_line.to_i,
231
+ start_col.to_i,
232
+ end_line.to_i,
233
+ end_col.to_i
234
+ ]
235
+ entry = (merged[key] ||= {
236
+ key: key,
237
+ file: key[0],
238
+ start_line: key[1],
239
+ start_col: key[2],
240
+ end_line: key[3],
241
+ end_col: key[4],
242
+ values: [],
243
+ total: 0
244
+ })
245
+
246
+ vals = e["values"] || e[:values] || [e["value"] || e[:value]].compact
247
+ entry[:values].concat(vals)
248
+ entry[:total] += (e["total"] || e[:total] || vals.length)
249
+ end
250
+ merged.values
251
+ end
252
+
253
+ def self.normalize_ranges(ranges)
254
+ return nil unless ranges
255
+ ranges.map do |r|
256
+ a = (r.respond_to?(:begin) ? r.begin : r[0]).to_i
257
+ b = (r.respond_to?(:end) ? r.end : r[1]).to_i
258
+ a <= b ? [a, b] : [b, a]
259
+ end
260
+ end
261
+
262
+ def self.normalize_ranges_by_file(input)
263
+ return nil unless input
264
+ input.each_with_object({}) do |(file, ranges), h|
265
+ abs = File.expand_path(file)
266
+ if ranges.nil? || ranges.empty?
267
+ h[abs] = []
268
+ else
269
+ h[abs] = normalize_ranges(ranges)
270
+ end
271
+ end
272
+ end
273
+
274
+ def self.line_in_ranges?(line, ranges)
275
+ return true if ranges.empty?
276
+ ranges.any? { |(s, e)| line >= s && line <= e }
277
+ end
278
+
279
+ def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil)
280
+ raw_events = JSON.parse(File.read(events_path))
281
+ events = normalize_events(raw_events)
282
+ render_all_from_events(events, root: root, ranges_by_file: ranges_by_file)
283
+ end
284
+
285
+ def self.render_all_from_events(events, root: Dir.pwd, ranges_by_file: nil)
286
+ events = normalize_events(events)
287
+ by_file = events.group_by { |e| e[:file] }
288
+ ranges_by_file = normalize_ranges_by_file(ranges_by_file)
289
+
290
+ target_paths = if ranges_by_file
291
+ ranges_by_file.keys
292
+ else
293
+ by_file.keys
294
+ end
295
+
296
+ sections = target_paths.sort.map do |path|
297
+ next unless File.exist?(path)
298
+ src = File.read(path)
299
+ ranges = ranges_by_file ? (ranges_by_file[path] || []) : nil
300
+ html_lines = src.lines.each_with_index.map do |line, idx|
301
+ lineno = idx + 1
302
+ next if ranges && !line_in_ranges?(lineno, ranges)
303
+ line_text = line.chomp
304
+ evs = aggregate_events_for_line(by_file[path] || [], lineno, line_text.length)
305
+ if evs.empty?
306
+ "<span class=\"line\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
307
+ else
308
+ rendered = render_line_with_events(line_text, evs)
309
+ "<span class=\"line hit\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
310
+ end
311
+ end.compact
312
+
313
+ rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
314
+ <<~HTML
315
+ <h2 class="file">#{esc(rel)}</h2>
316
+ <pre class="code"><code>
317
+ #{html_lines.join("")}
318
+ </code></pre>
319
+ HTML
320
+ end.compact.join("\n")
321
+
322
+ <<~HTML
323
+ <!doctype html>
324
+ <html>
325
+ <head>
326
+ <meta charset="utf-8">
327
+ <title>Recorded Result View</title>
328
+ <style>
329
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
330
+ .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
331
+ .line { display: inline; padding: 2px 8px; }
332
+ .line:hover { background: #fff2c6; }
333
+ .line.hit { background: #f0ffe7; }
334
+ .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
335
+ .hint { color: #666; margin-bottom: 8px; }
336
+ .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
337
+ .expr { position: relative; display: inline-block; padding-bottom: 1px; }
338
+ .expr.hit { }
339
+ .expr.depth-1 { --hl: #7fbf7f; }
340
+ .expr.depth-2 { --hl: #6fa8ff; }
341
+ .expr.depth-3 { --hl: #ffb347; }
342
+ .expr.depth-4 { --hl: #d78bff; }
343
+ .expr.depth-5 { --hl: #ff6f91; }
344
+ .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
345
+ .marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
346
+ .marker .tooltip {
347
+ display: none;
348
+ position: absolute;
349
+ left: 0;
350
+ top: 100%;
351
+ margin-top: 4px;
352
+ background: #2b2b2b;
353
+ color: #fff;
354
+ padding: 4px 6px;
355
+ border-radius: 4px;
356
+ font-size: 12px;
357
+ white-space: pre;
358
+ min-width: 16ch;
359
+ max-width: 90vw;
360
+ overflow-x: auto;
361
+ overflow-y: hidden;
362
+ z-index: 10;
363
+ pointer-events: auto;
364
+ }
365
+ .marker:hover .tooltip,
366
+ .marker:focus-within .tooltip,
367
+ .marker .tooltip:hover { display: block; }
368
+ </style>
369
+ </head>
370
+ <body>
371
+ <div class="hint">Hover highlighted text to see recorded values.</div>
372
+ #{sections}
373
+ <script>
374
+ (function() {
375
+ document.querySelectorAll('.marker').forEach(marker => {
376
+ marker.addEventListener('mouseenter', () => {
377
+ document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
378
+ marker.closest('.expr')?.classList.add('active');
379
+ });
380
+ marker.addEventListener('mouseleave', () => {
381
+ marker.closest('.expr')?.classList.remove('active');
382
+ });
383
+ });
384
+ })();
385
+ </script>
386
+ </body>
387
+ </html>
388
+ HTML
389
+ end
390
+ end
391
+
392
+ if $PROGRAM_NAME == __FILE__
393
+ source_path = ARGV[0] or abort "usage: ruby generate_resulted_html.rb SOURCE_PATH EVENTS_PATH"
394
+ events_path = ARGV[1] or abort "usage: ruby generate_resulted_html.rb SOURCE_PATH EVENTS_PATH"
395
+ puts GenerateResultedHtml.render(source_path, events_path)
396
+ end
397
+ end
@@ -0,0 +1,234 @@
1
+ require "json"
2
+ require "prism"
3
+
4
+ module Lumitrace
5
+ module RecordInstrument
6
+ SKIP_NODE_CLASSES = [
7
+ Prism::DefNode,
8
+ Prism::ClassNode,
9
+ Prism::ModuleNode,
10
+ Prism::IfNode,
11
+ Prism::UnlessNode,
12
+ Prism::WhileNode,
13
+ Prism::UntilNode,
14
+ Prism::ForNode,
15
+ Prism::CaseNode,
16
+ Prism::BeginNode,
17
+ Prism::RescueNode,
18
+ Prism::EnsureNode,
19
+ Prism::AliasMethodNode,
20
+ Prism::UndefNode
21
+ ].freeze
22
+
23
+ LITERAL_NODE_CLASSES = [
24
+ Prism::IntegerNode,
25
+ Prism::FloatNode,
26
+ Prism::RationalNode,
27
+ Prism::ImaginaryNode,
28
+ Prism::StringNode,
29
+ Prism::SymbolNode,
30
+ Prism::TrueNode,
31
+ Prism::FalseNode,
32
+ Prism::NilNode
33
+ ].freeze
34
+
35
+ WRAP_NODE_CLASSES = [
36
+ Prism::CallNode,
37
+ Prism::LocalVariableReadNode,
38
+ Prism::ConstantReadNode,
39
+ Prism::InstanceVariableReadNode,
40
+ Prism::ClassVariableReadNode,
41
+ Prism::GlobalVariableReadNode
42
+ ].freeze
43
+
44
+ def self.instrument_source(src, ranges, file_label: nil, record_method: "Lumitrace::RecordInstrument.expr_record")
45
+ file_label ||= "(unknown)"
46
+ ranges = normalize_ranges(ranges)
47
+
48
+ parse = Prism.parse(src)
49
+ if parse.errors.any?
50
+ raise "parse errors: #{parse.errors.map(&:message).join(", ") }"
51
+ end
52
+
53
+ inserts = collect_inserts(parse.value, src, ranges, file_label, record_method)
54
+
55
+ apply_insertions(src, inserts)
56
+ end
57
+
58
+ def self.collect_inserts(root, src, ranges, file_label, record_method)
59
+ inserts = []
60
+ stack = [[root, nil]]
61
+
62
+ until stack.empty?
63
+ node, parent = stack.pop
64
+ next unless node
65
+
66
+ if node.respond_to?(:location)
67
+ line = node.location.start_line
68
+ if in_ranges?(line, ranges) && wrap_expr?(node, parent)
69
+ loc = expr_location(node)
70
+ prefix = "#{record_method}(\"#{file_label}\", #{loc[:start_line]}, #{loc[:start_col]}, #{loc[:end_line]}, #{loc[:end_col]}, ("
71
+ suffix = "))"
72
+ span_len = loc[:end_offset] - loc[:start_offset]
73
+ inserts << { pos: loc[:start_offset], text: prefix, kind: :open, len: span_len }
74
+ inserts << { pos: loc[:end_offset], text: suffix, kind: :close, len: span_len }
75
+ end
76
+ end
77
+
78
+ node.child_nodes.each { |child| stack << [child, node] }
79
+ end
80
+
81
+ inserts
82
+ end
83
+
84
+ def self.normalize_ranges(ranges)
85
+ ranges.map do |r|
86
+ a = r[0].to_i
87
+ b = r[1].to_i
88
+ a <= b ? [a, b] : [b, a]
89
+ end
90
+ end
91
+
92
+ def self.in_ranges?(line, ranges)
93
+ return true if ranges.empty?
94
+ ranges.any? { |(s, e)| line >= s && line <= e }
95
+ end
96
+
97
+ def self.apply_insertions(src, inserts)
98
+ out = src.dup
99
+ kind_order = { open: 0, close: 1 }
100
+ inserts.sort_by do |i|
101
+ [
102
+ -i[:pos],
103
+ kind_order[i[:kind]],
104
+ i[:kind] == :open ? i[:len] : -i[:len]
105
+ ]
106
+ end.each do |i|
107
+ out.insert(i[:pos], i[:text])
108
+ end
109
+ out
110
+ end
111
+
112
+ def self.literal_value_node?(node)
113
+ LITERAL_NODE_CLASSES.include?(node.class)
114
+ end
115
+
116
+ def self.wrap_expr?(node, parent = nil)
117
+ return false unless node.respond_to?(:location)
118
+ return false if literal_value_node?(node)
119
+ return false if node.is_a?(Prism::CallNode) && has_block_with_body?(node)
120
+ if node.is_a?(Prism::ConstantReadNode) &&
121
+ (parent.is_a?(Prism::ClassNode) || parent.is_a?(Prism::ModuleNode))
122
+ return false
123
+ end
124
+ WRAP_NODE_CLASSES.include?(node.class)
125
+ end
126
+
127
+ def self.expr_location(node)
128
+ loc = node.location
129
+ return {
130
+ start_offset: loc.start_offset,
131
+ end_offset: loc.start_offset + loc.length,
132
+ start_line: loc.start_line,
133
+ start_col: loc.start_column,
134
+ end_line: loc.end_line,
135
+ end_col: loc.end_column
136
+ } unless node.is_a?(Prism::CallNode)
137
+
138
+ best = loc
139
+ [node.arguments&.location, node.block&.location, node.closing_loc].compact.each do |l|
140
+ next unless l
141
+ best = l if (l.start_offset + l.length) >= (best.start_offset + best.length)
142
+ end
143
+
144
+ {
145
+ start_offset: loc.start_offset,
146
+ end_offset: best.start_offset + best.length,
147
+ start_line: loc.start_line,
148
+ start_col: loc.start_column,
149
+ end_line: best.end_line,
150
+ end_col: best.end_column
151
+ }
152
+ end
153
+
154
+ def self.has_block_with_body?(call_node)
155
+ block = call_node.child_nodes.find { |n| n.is_a?(Prism::BlockNode) }
156
+ block && block.body.is_a?(Prism::StatementsNode)
157
+ end
158
+
159
+ @events_by_key = {}
160
+ @max_values_per_expr = 3
161
+
162
+ def self.max_values_per_expr=(n)
163
+ @max_values_per_expr = n.to_i if n && n.to_i > 0
164
+ end
165
+
166
+ def self.max_values_per_expr
167
+ @max_values_per_expr
168
+ end
169
+
170
+ def self.expr_record(file, start_line, start_col, end_line, end_col, value)
171
+ key = [file, start_line, start_col, end_line, end_col]
172
+ entry = (@events_by_key[key] ||= {
173
+ file: file,
174
+ start_line: start_line,
175
+ start_col: start_col,
176
+ end_line: end_line,
177
+ end_col: end_col,
178
+ values: [],
179
+ total: 0
180
+ })
181
+
182
+ entry[:total] += 1
183
+ entry[:values] << safe_value(value)
184
+ if entry[:values].length > @max_values_per_expr
185
+ entry[:values].shift(entry[:values].length - @max_values_per_expr)
186
+ end
187
+ value
188
+ end
189
+
190
+ def self.dump_json(path = nil)
191
+ path ||= File.expand_path("lumitrace_recorded.json", Dir.pwd)
192
+ File.write(path, JSON.dump(@events_by_key.values))
193
+ path
194
+ end
195
+
196
+ def self.events
197
+ @events_by_key.values.map do |e|
198
+ {
199
+ file: e[:file],
200
+ start_line: e[:start_line],
201
+ start_col: e[:start_col],
202
+ end_line: e[:end_line],
203
+ end_col: e[:end_col],
204
+ values: e[:values].dup,
205
+ total: e[:total]
206
+ }
207
+ end
208
+ end
209
+
210
+ def self.safe_value(v)
211
+ case v
212
+ when String
213
+ v.bytesize > 1000 ? v[0, 1000] + "..." : v
214
+ when Numeric, TrueClass, FalseClass, NilClass
215
+ v
216
+ else
217
+ v.inspect
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ if $PROGRAM_NAME == __FILE__
224
+ path = ARGV[0] or abort "usage: ruby record_instrument.rb FILE RANGES_JSON [record_method] [out_path]"
225
+ ranges = JSON.parse(ARGV[1] || "[]")
226
+ record_method = ARGV[2] || "Lumitrace::RecordInstrument.expr_record"
227
+ out = RecordInstrument.instrument_source(File.read(path), ranges, file_label: path, record_method: record_method)
228
+
229
+ if ARGV[3]
230
+ File.write(ARGV[3], out)
231
+ else
232
+ puts out
233
+ end
234
+ end
@@ -0,0 +1,110 @@
1
+ require "json"
2
+ require "prism"
3
+ require_relative "./record_instrument"
4
+
5
+ module Lumitrace
6
+ module RecordRequire
7
+ @enabled = false
8
+ @processed = {}
9
+ @root = File.expand_path(ENV.fetch("LUMITRACE_ROOT", Dir.pwd))
10
+ @tool_root = File.expand_path(__dir__)
11
+ @ranges_by_file = {}
12
+ @ranges_filtering = false
13
+
14
+ def self.enable(max_values: nil, ranges_by_file: nil)
15
+ return if @enabled
16
+ RecordInstrument.max_values_per_expr = max_values if max_values
17
+ if ranges_by_file
18
+ @ranges_by_file = normalize_ranges_by_file(ranges_by_file)
19
+ @ranges_filtering = true
20
+ else
21
+ @ranges_by_file = {}
22
+ @ranges_filtering = false
23
+ end
24
+ @enabled = true
25
+ end
26
+
27
+ def self.ranges_for(path)
28
+ return [] unless @ranges_filtering
29
+ @ranges_by_file[File.expand_path(path)] || []
30
+ end
31
+
32
+ def self.ranges_filtering?
33
+ @ranges_filtering
34
+ end
35
+
36
+ def self.listed_file?(path)
37
+ @ranges_by_file.key?(File.expand_path(path))
38
+ end
39
+
40
+ def self.normalize_ranges_by_file(input)
41
+ return {} unless input
42
+ input.each_with_object({}) do |(file, ranges), h|
43
+ next unless file
44
+ abs = File.expand_path(file)
45
+ if ranges.nil? || ranges.empty?
46
+ h[abs] = []
47
+ else
48
+ h[abs] = ranges.map { |r| [r.begin, r.end] }
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.disable
54
+ @enabled = false
55
+ end
56
+
57
+ def self.enabled?
58
+ @enabled
59
+ end
60
+
61
+ def self.in_root?(path)
62
+ abs = File.expand_path(path)
63
+ abs.start_with?(@root + File::SEPARATOR)
64
+ end
65
+
66
+ def self.excluded?(path)
67
+ abs = File.expand_path(path)
68
+ abs.start_with?(@tool_root + File::SEPARATOR)
69
+ end
70
+
71
+ def self.already_processed?(path)
72
+ @processed[path]
73
+ end
74
+
75
+ def self.mark_processed(path)
76
+ @processed[path] = true
77
+ end
78
+ end
79
+
80
+ if defined?(RubyVM::InstructionSequence)
81
+ class RubyVM::InstructionSequence
82
+ class << self
83
+ if respond_to?(:translate)
84
+ alias_method :recordrequire_orig_translate, :translate
85
+ end
86
+
87
+ def translate(iseq)
88
+ return recordrequire_orig_translate(iseq) if respond_to?(:recordrequire_orig_translate) && !RecordRequire.enabled?
89
+ path = iseq.path
90
+ abs = File.expand_path(path)
91
+ if RecordRequire.in_root?(abs) && !RecordRequire.excluded?(abs) && !RecordRequire.already_processed?(abs) &&
92
+ (iseq.label == "<main>" || iseq.label == "<top (required)>")
93
+ if RecordRequire.ranges_filtering? && !RecordRequire.listed_file?(abs)
94
+ return recordrequire_orig_translate(iseq) if respond_to?(:recordrequire_orig_translate)
95
+ return nil
96
+ end
97
+ RecordRequire.mark_processed(abs)
98
+ src = File.read(abs)
99
+ ranges = RecordRequire.ranges_for(abs)
100
+ modified = RecordInstrument.instrument_source(src, ranges, file_label: abs)
101
+ return RubyVM::InstructionSequence.compile(modified, abs)
102
+ end
103
+ return recordrequire_orig_translate(iseq) if respond_to?(:recordrequire_orig_translate)
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lumitrace
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lumitrace.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lumitrace/version"
4
+ require_relative "lumitrace/record_instrument"
5
+ require_relative "lumitrace/generate_resulted_html"
6
+
7
+ module Lumitrace
8
+ class Error < StandardError; end
9
+ @atexit_registered = false
10
+ @atexit_output_root = nil
11
+ @atexit_ranges_by_file = nil
12
+
13
+ def self.enable!(max_values: ENV["LUMITRACE_VALUES_MAX"], ranges_by_file: nil, at_exit: true)
14
+ require_relative "lumitrace/record_require"
15
+ RecordRequire.enable(max_values: max_values, ranges_by_file: ranges_by_file)
16
+ if at_exit
17
+ @atexit_output_root = Dir.pwd
18
+ @atexit_ranges_by_file = ranges_by_file
19
+ unless @atexit_registered
20
+ at_exit do
21
+ next unless RecordRequire.enabled?
22
+ if ENV["LUMITRACE_JSON_OUT"] && !ENV["LUMITRACE_JSON_OUT"].empty?
23
+ RecordInstrument.dump_json(File.expand_path(ENV["LUMITRACE_JSON_OUT"], @atexit_output_root))
24
+ end
25
+ events = RecordInstrument.events
26
+ html = GenerateResultedHtml.render_all_from_events(
27
+ events,
28
+ root: @atexit_output_root,
29
+ ranges_by_file: @atexit_ranges_by_file
30
+ )
31
+ out_path = ENV.fetch("LUMITRACE_HTML_OUT", File.expand_path("lumitrace_recorded.html", @atexit_output_root))
32
+ File.write(out_path, html)
33
+ end
34
+ @atexit_registered = true
35
+ end
36
+ end
37
+ end
38
+
39
+ def self.disable!
40
+ return unless defined?(RecordRequire)
41
+ RecordRequire.disable
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ name: Lumitrace Sample
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+
7
+ jobs:
8
+ run-lumitrace:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout
12
+ uses: actions/checkout@v4
13
+ with:
14
+ fetch-depth: 0
15
+
16
+ - name: Setup Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: "3.2"
20
+
21
+ - name: Install dependencies
22
+ run: bundle install
23
+
24
+ - name: Run tests with Lumitrace
25
+ env:
26
+ LUMITRACE_GIT_DIFF: "range:origin/${{ github.base_ref || HEAD~1 }}...HEAD"
27
+ LUMITRACE_HTML_OUT: "${{ github.workspace }}/lumitrace_recorded.html"
28
+ run: RUBYOPT='-r lumitrace/enable_git_diff' bundle exec rake test
29
+
30
+ - name: Upload Lumitrace HTML
31
+ uses: actions/upload-artifact@v4
32
+ with:
33
+ name: lumitrace-recorded-html
34
+ path: lumitrace_recorded.html
35
+
36
+ - name: Artifact URL
37
+ run: |
38
+ echo "Artifact URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_STEP_SUMMARY
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "lumitrace", path: ".."
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.test_files = FileList["test/**/*_test.rb", "test/**/test_*.rb"]
8
+ t.verbose = false
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../test"
5
+
6
+ class SampleProjectTest < Minitest::Test
7
+ def test_compute
8
+ assert_equal 13, compute(5)
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ def compute(x)
4
+ y = x * 2
5
+ z = y + 3
6
+ z
7
+ end
8
+
9
+ puts compute(5)
data/sig/lumitrace.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Lumitrace
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "json"
5
+ require "tmpdir"
6
+ require_relative "../lib/lumitrace"
7
+
8
+ class LumiTraceTest < Minitest::Test
9
+ def test_namespaces_loaded
10
+ assert defined?(Lumitrace::RecordInstrument)
11
+ assert defined?(Lumitrace::GenerateResultedHtml)
12
+ end
13
+
14
+ def test_instrument_wraps_calls_and_reads
15
+ src = <<~RUBY
16
+ def compute(x)
17
+ v1 = add(x, 2)
18
+ v2 = mul(v1, 3)
19
+ end
20
+ RUBY
21
+
22
+ out = Lumitrace::RecordInstrument.instrument_source(src, [], file_label: "sample.rb")
23
+
24
+ assert_includes out, "Lumitrace::RecordInstrument.expr_record(\"sample.rb\", 2, 7,"
25
+ assert_includes out, "Lumitrace::RecordInstrument.expr_record(\"sample.rb\", 2, 11,"
26
+ end
27
+
28
+ def test_render_all_generates_html
29
+ Dir.mktmpdir do |dir|
30
+ path = File.join(dir, "lumitrace_events.json")
31
+ sample = File.join(dir, "sample.rb")
32
+ events = [
33
+ {
34
+ "file" => sample,
35
+ "start_line" => 1,
36
+ "start_col" => 0,
37
+ "end_line" => 1,
38
+ "end_col" => 5,
39
+ "values" => ["ok"],
40
+ "total" => 1
41
+ }
42
+ ]
43
+
44
+ File.write(path, JSON.dump(events))
45
+ File.write(sample, "puts hi\n")
46
+
47
+ html = Lumitrace::GenerateResultedHtml.render_all(path, root: dir)
48
+ assert_includes html, "Recorded Result View"
49
+ assert_includes html, "sample.rb"
50
+ end
51
+ end
52
+
53
+ def test_require_instruments_multiple_files
54
+ skip "RubyVM::InstructionSequence unavailable" unless defined?(RubyVM::InstructionSequence)
55
+
56
+ Dir.mktmpdir do |dir|
57
+ ENV["LUMITRACE_ROOT"] = dir
58
+
59
+ main = File.join(dir, "main.rb")
60
+ sub = File.join(dir, "sub.rb")
61
+
62
+ File.write(sub, <<~RUBY)
63
+ def sub_value(x)
64
+ x + 1
65
+ end
66
+ RUBY
67
+
68
+ File.write(main, <<~RUBY)
69
+ require_relative "./sub"
70
+ sub_value(10)
71
+ RUBY
72
+
73
+ Lumitrace.enable!(max_values: 3, at_exit: false)
74
+ Lumitrace::RecordInstrument.instance_variable_set(:@events_by_key, {})
75
+
76
+ load main
77
+
78
+ out = File.join(dir, "events.json")
79
+ Lumitrace::RecordInstrument.dump_json(out)
80
+ events = JSON.parse(File.read(out))
81
+ files = events.map { |e| e["file"] }.uniq
82
+
83
+ assert_includes files, main
84
+ assert_includes files, sub
85
+ end
86
+ ensure
87
+ ENV.delete("LUMITRACE_ROOT")
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lumitrace
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Koichi Sasada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Inline expression tracing for Ruby
13
+ email:
14
+ - ko1@atdot.net
15
+ executables:
16
+ - lumitrace
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - Rakefile
22
+ - exe/lumitrace
23
+ - lib/lumitrace.rb
24
+ - lib/lumitrace/enable.rb
25
+ - lib/lumitrace/enable_git_diff.rb
26
+ - lib/lumitrace/generate_resulted_html.rb
27
+ - lib/lumitrace/record_instrument.rb
28
+ - lib/lumitrace/record_require.rb
29
+ - lib/lumitrace/version.rb
30
+ - sample_project/.github/workflows/lumitrace-sample.yml
31
+ - sample_project/Gemfile
32
+ - sample_project/Rakefile
33
+ - sample_project/test.rb
34
+ - sample_project/test/test_sample_test.rb
35
+ - sig/lumitrace.rbs
36
+ - test/test_lumitrace.rb
37
+ homepage: https://github.com/ko1/lumitrace
38
+ licenses: []
39
+ metadata:
40
+ homepage_uri: https://github.com/ko1/lumitrace
41
+ source_code_uri: https://github.com/ko1/lumitrace
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.2.0
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.3
57
+ specification_version: 4
58
+ summary: Inline expression tracing for Ruby
59
+ test_files: []