lumitrace 0.5.0 → 0.6.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb5fe18ff96f6cea6cac519424f9d7a3ead8e4ce4f9be3ad2db7ddde2804302e
4
- data.tar.gz: 17c83251a9b042481e297422f2ddcad5f923a890f52202331e3fc85e28a9e888
3
+ metadata.gz: bbe02bbc074d7d4ef9a4279ea426500296ea6aca0b560c146140f5e143ea4f8f
4
+ data.tar.gz: 6d0101d97cf784392d3f62a90edfc29a244b6a6582da9b15800447d8b3ad9175
5
5
  SHA512:
6
- metadata.gz: eb65e28bfad568fb9aaff142a4ee0150e49b8f37cf8ffb99508c706691a710310119b23826942668874a60a6189a91732367389581bd056a9cdc09592a286d0a
7
- data.tar.gz: dbbbfb431b4a957f186a93a849949fac88b54ed8408b9d761550c0eeb76055195f589e4ca9dfdd23152854ed156be6d13172337a555441bf0960d05825ef7332
6
+ metadata.gz: bb1c57562f18e6827bcf0f425e45ab098182553ba9f44f311f4302f8c919e133903a2dbe97695c37914ad01415709deb68445d6109ccaa1f87a3979bb5dd307a
7
+ data.tar.gz: 8f863f58547b5e5ab455b07857919383e2784d18dc3c32486b3ff94e84d799deae180938d4c6ef3cb94bac0a8bd8689c5caf0b9e6b11f1dc52ed8f6bcb81c936
data/README.md CHANGED
@@ -67,7 +67,8 @@ lumitrace help --format json
67
67
  lumitrace schema --format json
68
68
  ```
69
69
 
70
- AI-oriented usage guide (Japanese):
70
+ AI-oriented usage guide:
71
+ - `docs/tutorial.md` section "Using with AI agents"
71
72
  - `docs/tutorial.ja.md` section "AI と使う"
72
73
 
73
74
  ### Library
data/docs/ai-help.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Lumitrace Help
4
4
 
5
- - Version: 0.4.1
5
+ - Version: 0.6.1
6
6
  - Help version: 1
7
7
  - Primary JSON entrypoint: `lumitrace help --format json`
8
8
  - Schema JSON entrypoint: `lumitrace schema --format json`
@@ -37,11 +37,16 @@
37
37
  ## Key Options
38
38
  - `--collect-mode` (default="last"; values=last,types,history)
39
39
  - `--max-samples` (default=3; Used by history mode.)
40
- - `--json[=PATH]` (Emit JSON output.)
41
- - `--html[=PATH]` (Emit HTML output.)
42
- - `--text[=PATH]` (Emit text output.)
40
+ - `-j, --json[=PATH]` (Emit JSON output.)
41
+ - `-h, --html[=PATH]` (Emit HTML output.)
42
+ - `-t, --text[=PATH]` (Emit text output.)
43
+ - `-g, --git-diff[=MODE]` (Restrict instrumentation to diff hunks.)
43
44
  - `--range SPEC` (repeatable=true; Restrict instrumentation to file ranges.)
44
- - `--git-diff[=MODE]` (Restrict instrumentation to diff hunks.)
45
+ - `--git-diff-context N` (Expand diff hunks by +/-N lines.)
46
+ - `--git-cmd PATH` (Git executable for diff.)
47
+ - `--git-diff-no-untracked` (Exclude untracked files from diff.)
48
+ - `--root PATH` (Root directory for instrumentation.)
49
+ - `--verbose[=LEVEL]` (Verbose logs to stderr (level 1-3).)
45
50
 
46
51
  ## Examples
47
52
  - `lumitrace --collect-mode history --max-samples 5 -j app.rb`
data/docs/ai-schema.md CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  # Lumitrace JSON Schema
4
4
 
5
- - Version: 0.4.1
5
+ - Version: 0.6.1
6
6
  - Schema version: 1
7
- - Top level: array of event
7
+ - Top level: object
8
+ - `version` (integer) - Schema version number.
9
+ - `events` (array of event) - Traced expression events.
10
+ - `coverage` (array of coverage_entry) - Per-file coverage summary.
8
11
 
9
12
  ## Common Event Fields
10
13
  - `file` (string, required) - Absolute source path.
@@ -14,9 +17,15 @@
14
17
  - `end_col` (integer, required)
15
18
  - `kind` (string, required)
16
19
  - `name` (string|null, optional) - Present for kind=arg.
17
- - `total` (integer, required) - Execution count.
20
+ - `total` (integer, required) - Execution count. 0 means the expression was never executed (uncovered).
18
21
  - `types` (object, required) - Ruby class name => observed count.
19
22
 
23
+ ## Coverage Entry Fields
24
+ - `file` (string, required) - Absolute source path.
25
+ - `total_lines` (integer, required) - Number of traced expression lines.
26
+ - `covered_lines` (integer, required) - Lines with total > 0.
27
+ - `coverage_percent` (float, required) - Covered / total * 100, rounded to 1 decimal.
28
+
20
29
  ## Value Summary Fields
21
30
  - `type` (string, required) - Ruby class name.
22
31
  - `preview` (string, required) - Value preview string (inspect-based).
data/docs/spec.md CHANGED
@@ -167,7 +167,21 @@ Lumitrace instruments Ruby source code at load time (via `RubyVM::InstructionSeq
167
167
 
168
168
  ### Output JSON
169
169
 
170
- `lumitrace_recorded.json` contains an array of entries.
170
+ `lumitrace_recorded.json` is a JSON object with the following top-level structure:
171
+
172
+ ```json
173
+ {
174
+ "version": 1,
175
+ "events": [ ... ],
176
+ "coverage": [ ... ]
177
+ }
178
+ ```
179
+
180
+ - `version`: Schema version (currently `1`).
181
+ - `events`: Array of trace event entries (see below).
182
+ - `coverage`: Array of per-file coverage summaries (see below).
183
+
184
+ #### Event entries
171
185
 
172
186
  `collect_mode=last` (default):
173
187
 
@@ -226,6 +240,24 @@ Lumitrace instruments Ruby source code at load time (via `RubyVM::InstructionSeq
226
240
  - `last_value`: summary of the last observed value: `{ type, preview }` (+ `length` only when truncated).
227
241
  - `types`: observed Ruby class counts (class name => count).
228
242
  - `sampled_values`: retained sample (last N values) of summary objects (`{ type, preview }` + optional `length`) in `history` mode.
243
+ - `total`: total execution count. When `0`, the expression was instrumented but never executed (uncovered).
244
+
245
+ #### Coverage summary
246
+
247
+ Each entry in the `coverage` array summarizes line-level coverage for one file:
248
+
249
+ ```json
250
+ {
251
+ "file": "/path/to/file.rb",
252
+ "total_lines": 18,
253
+ "covered_lines": 12,
254
+ "coverage_percent": 66.7
255
+ }
256
+ ```
257
+
258
+ - `total_lines`: number of lines that contain at least one instrumented expression.
259
+ - `covered_lines`: number of those lines where at least one expression has `total > 0`.
260
+ - `coverage_percent`: `covered_lines / total_lines * 100`, rounded to one decimal place.
229
261
 
230
262
  ## CLI
231
263
 
@@ -274,8 +306,10 @@ lumitrace [options] exec CMD [args...]
274
306
  - `Mode: types (type counts)`
275
307
  - `Mode: history (last N sample[s])`
276
308
  - In `history`, `N` uses configured `max_samples` when available; otherwise it is inferred from the loaded events.
309
+ - When available, the page header also shows the executed command as `Command: ...`.
277
310
  - Each file is shown in its own section.
278
311
  - When multiple files are present, the HTML UI shows a left file tree and a single-file viewer on the right.
312
+ - Directory nodes in the file tree are expanded by default.
279
313
  - Selecting a file in the tree switches the visible file without reloading the page.
280
314
  - The selected file is reflected in the URL hash as `#file=...` (using the rendered file path label) so links can open a specific file view.
281
315
  - Clicking a line number updates the URL hash to include the selected line (for example `#file=lib/foo.rb&line=42`).
@@ -286,8 +320,10 @@ lumitrace [options] exec CMD [args...]
286
320
  - Hovering the icon shows recorded values.
287
321
  - Only the last 3 values are shown in the tooltip as `value (Type)`; additional values are summarized as `... (+N more)`.
288
322
  - Tooltip is scrollable horizontally for long values.
323
+ - Tooltip is shown above the marker icon (to avoid covering the mouse cursor).
289
324
  - When ranges are used, skipped sections are shown as `...` in the line-number column.
290
325
  - Lines where all instrumentable expressions are unexecuted are highlighted in a light red. If a line mixes executed and unexecuted expressions, only the unexecuted expressions are highlighted.
326
+ - The page footer includes an attribution link to the Lumitrace site and the Lumitrace version used to generate the report.
291
327
 
292
328
  ### HTML Payload Schema (`v1`)
293
329
 
@@ -332,6 +368,8 @@ lumitrace [options] exec CMD [args...]
332
368
  - Human-readable label shown in the HTML header.
333
369
  - `meta.max_samples`:
334
370
  - Effective/inferred max samples for `history`; may be `null`.
371
+ - `meta.command`:
372
+ - Optional command line shown in the HTML header (for CLI-generated reports).
335
373
  - `files[]`:
336
374
  - One entry per rendered source file.
337
375
  - `files[].path`:
@@ -367,4 +405,5 @@ lumitrace [options] exec CMD [args...]
367
405
 
368
406
  - Requires `RubyVM::InstructionSequence.translate` support in the Ruby build.
369
407
  - Instrumentation is for debugging; semantics may change for unusual edge cases.
408
+ - Expressions inside `defined?(...)` are intentionally not instrumented to preserve `defined?` semantics.
370
409
  - Tool does not attempt to preserve file encoding comments.
@@ -112,6 +112,13 @@ These are intentionally skipped to keep output valid Ruby:
112
112
  ```
113
113
  - The implicit `token` read is **not** instrumented.
114
114
 
115
+ - Expressions inside `defined?(...)`:
116
+ - Example:
117
+ ```ruby
118
+ defined?(foo + bar)
119
+ ```
120
+ - The operand expression (`foo + bar`) is **not** instrumented, to preserve `defined?` semantics.
121
+
115
122
  ## Rationale
116
123
 
117
124
  All skips above correspond to syntactic positions where wrapping the token with
data/docs/tutorial.ja.md CHANGED
@@ -131,9 +131,72 @@ HTML について:
131
131
  - 記録対象の式がすべて未実行の行は薄い赤で表示され、混在行では未実行の式のみ薄赤になります。
132
132
  - 範囲指定時の省略は行番号欄に `...` が入ります。
133
133
  - 複数ファイルが含まれる場合は、左側のファイルツリーで表示ファイルを切り替えられます。
134
+ - ファイルツリーのディレクトリはデフォルトで展開されています。
134
135
  - ファイルツリーには、ファイルごとの行カバレッジが `(実行行数/式のある行数)` で表示されます。
135
136
  - 選択中ファイルは URL ハッシュ(`#file=...`)に反映されるため、URL を共有するとそのファイル表示を開けます。
136
137
  - 行番号をクリックすると、その行を含む URL ハッシュ(`#file=...&line=...`)に更新され、同じファイル・行を開けます。
138
+ - 移動時に URL ハッシュは自動更新されるため、専用の permalink ボタンはありません。
139
+ - CLI から生成した HTML では、ヘッダに `Command: ...` が表示されます。
140
+ - フッターには、HTML を生成した Lumitrace のバージョンが表示されます。
141
+
142
+ ### AI と使う
143
+
144
+ テストが落ちたとき、Lumitrace で実行時の値を取得し、その結果を AI に渡すと効率的にデバッグできます。
145
+
146
+ **典型的なシナリオ:**
147
+
148
+ 1. テストが失敗する
149
+ 2. Lumitrace でテスト実行時の値を記録する
150
+ 3. 結果を AI に渡して原因を分析してもらう
151
+
152
+ ```bash
153
+ # テストを lumitrace 付きで実行し、JSON を取得
154
+ lumitrace --collect-mode last -j exec rake test
155
+
156
+ # 結果の JSON を AI に渡す
157
+ ```
158
+
159
+ **CLAUDE.md / AGENTS.md に貼れるスニペット例:**
160
+
161
+ プロジェクトの `CLAUDE.md` や `AGENTS.md` に以下を追加すると、AI エージェントが Lumitrace を活用しやすくなります:
162
+
163
+ ````markdown
164
+ ## デバッグ(Lumitrace)
165
+ lumitrace は Ruby の各式の実行時の値を記録するツール。
166
+ テスト失敗時は `lumitrace help` を読んでから使うこと。
167
+ 基本: `lumitrace -t exec rake test`
168
+ ````
169
+
170
+ **段階的アプローチ(トークン節約):**
171
+
172
+ AI に読ませる前提なら、次の順番にすると効率が良いです。
173
+
174
+ 1. まず型分布だけ取る(安く全体像を見る)
175
+
176
+ ```bash
177
+ lumitrace --collect-mode types -j path/to/entry.rb
178
+ ```
179
+
180
+ 2. 次に最終値を見る(値の当たりを付ける)
181
+
182
+ ```bash
183
+ lumitrace --collect-mode last -j path/to/entry.rb
184
+ ```
185
+
186
+ 3. 変化が必要な箇所だけ履歴を見る
187
+
188
+ ```bash
189
+ lumitrace --collect-mode history --max-samples 5 -j path/to/entry.rb
190
+ ```
191
+
192
+ 4. 対象を絞る(トークン節約)
193
+
194
+ ```bash
195
+ lumitrace --collect-mode last -j --range path/to/entry.rb:120-180 path/to/entry.rb
196
+ lumitrace --collect-mode last -j -g path/to/entry.rb
197
+ ```
198
+
199
+ 補助情報は `lumitrace help --format json` と `lumitrace schema --format json` で機械可読に取得できます。
137
200
 
138
201
  ### 範囲指定の例
139
202
 
@@ -298,37 +361,6 @@ GitHub Actions への追加手順(`LUMITRACE_GIT_DIFF` や Pages へのアッ
298
361
 
299
362
  fork/exec の結果はデフォルトでマージされます。親プロセスが最終出力を行い、子プロセスは `LUMITRACE_RESULTS_DIR` に断片 JSON を保存します。
300
363
 
301
- ### AI と使う
302
-
303
- AI に読ませる前提なら、次の順番にすると効率が良いです。
304
-
305
- 1. まず型分布だけ取る(安く全体像を見る)
306
-
307
- ```bash
308
- lumitrace --collect-mode types -j path/to/entry.rb
309
- ```
310
-
311
- 2. 次に最終値を見る(値の当たりを付ける)
312
-
313
- ```bash
314
- lumitrace --collect-mode last -j path/to/entry.rb
315
- ```
316
-
317
- 3. 変化が必要な箇所だけ履歴を見る
318
-
319
- ```bash
320
- lumitrace --collect-mode history --max-samples 5 -j path/to/entry.rb
321
- ```
322
-
323
- 4. 対象を絞る(トークン節約)
324
-
325
- ```bash
326
- lumitrace --collect-mode last -j --range path/to/entry.rb:120-180 path/to/entry.rb
327
- lumitrace --collect-mode last -j -g path/to/entry.rb
328
- ```
329
-
330
- 補助情報は `lumitrace help --format json` と `lumitrace schema --format json` で機械可読に取得できます。
331
-
332
364
  ## 2. ライブラリとして使う
333
365
 
334
366
  CLI を使わずアプリに組み込みたい場合はここから始めます。
data/docs/tutorial.md CHANGED
@@ -131,9 +131,72 @@ HTML notes:
131
131
  - Lines where all instrumentable expressions are unexecuted are shaded light red; mixed lines only shade the unexecuted expressions.
132
132
  - When ranges are used, skipped sections are shown as `...` in the line-number column.
133
133
  - When multiple files are included, use the left file tree to switch files.
134
+ - Directory nodes in the file tree are expanded by default.
134
135
  - The file tree shows per-file line coverage as `(executed/expression-lines)`.
135
136
  - The selected file is stored in the URL hash (`#file=...`), so copying the URL shares a direct link to that file view.
136
137
  - Click a line number to update the URL with that line (`#file=...&line=...`) and open the same file+line later.
138
+ - The URL hash is updated automatically while navigating (there is no separate permalink button).
139
+ - The header also shows `Command: ...` for CLI-generated reports.
140
+ - The footer shows the Lumitrace version that generated the report.
141
+
142
+ ### Using with AI agents
143
+
144
+ When a test fails, you can use Lumitrace to capture runtime values and pass the results to an AI for efficient debugging.
145
+
146
+ **Typical scenario:**
147
+
148
+ 1. A test fails
149
+ 2. Run the test with Lumitrace to record runtime values
150
+ 3. Pass the results to an AI to analyze the root cause
151
+
152
+ ```bash
153
+ # Run tests with lumitrace and get JSON output
154
+ lumitrace --collect-mode last -j exec rake test
155
+
156
+ # Pass the resulting JSON to your AI
157
+ ```
158
+
159
+ **Snippet for CLAUDE.md / AGENTS.md:**
160
+
161
+ Add the following to your project's `CLAUDE.md` or `AGENTS.md` so AI agents can leverage Lumitrace:
162
+
163
+ ````markdown
164
+ ## Debugging (Lumitrace)
165
+ lumitrace is a tool that records runtime values of each Ruby expression.
166
+ When a test fails, read `lumitrace help` first, then use it.
167
+ Basic: `lumitrace -t exec rake test`
168
+ ````
169
+
170
+ **Gradual approach (save tokens):**
171
+
172
+ When feeding results to an AI, this order is efficient:
173
+
174
+ 1. Start with type distributions (cheap overview)
175
+
176
+ ```bash
177
+ lumitrace --collect-mode types -j path/to/entry.rb
178
+ ```
179
+
180
+ 2. Then check last values (get a feel for the data)
181
+
182
+ ```bash
183
+ lumitrace --collect-mode last -j path/to/entry.rb
184
+ ```
185
+
186
+ 3. Look at history only where changes are needed
187
+
188
+ ```bash
189
+ lumitrace --collect-mode history --max-samples 5 -j path/to/entry.rb
190
+ ```
191
+
192
+ 4. Narrow the scope (save tokens)
193
+
194
+ ```bash
195
+ lumitrace --collect-mode last -j --range path/to/entry.rb:120-180 path/to/entry.rb
196
+ lumitrace --collect-mode last -j -g path/to/entry.rb
197
+ ```
198
+
199
+ Machine-readable help is available via `lumitrace help --format json` and `lumitrace schema --format json`.
137
200
 
138
201
  ### Range example
139
202
 
@@ -70,7 +70,17 @@ module Lumitrace
70
70
  lines << ""
71
71
  lines << "- Version: #{data[:version]}"
72
72
  lines << "- Schema version: #{data[:schema_version]}"
73
- lines << "- Top level: #{data[:json_top_level][:type]} of #{data[:json_top_level][:items]}"
73
+ top = data[:json_top_level]
74
+ if top[:type] == "object" && top[:fields]
75
+ lines << "- Top level: #{top[:type]}"
76
+ top[:fields].each do |name, spec|
77
+ type_text = spec[:items] ? "#{spec[:type]} of #{spec[:items]}" : spec[:type].to_s
78
+ desc = spec[:description] ? " - #{spec[:description]}" : ""
79
+ lines << " - `#{name}` (#{type_text})#{desc}"
80
+ end
81
+ else
82
+ lines << "- Top level: #{top[:type]} of #{top[:items]}"
83
+ end
74
84
  lines << ""
75
85
  lines << "## Common Event Fields"
76
86
  data[:event_common_fields].each do |name, spec|
@@ -79,6 +89,16 @@ module Lumitrace
79
89
  desc = spec[:description] ? " - #{spec[:description]}" : ""
80
90
  lines << "- `#{name}` (#{type_text}, #{req})#{desc}"
81
91
  end
92
+ if data[:coverage_entry_fields]
93
+ lines << ""
94
+ lines << "## Coverage Entry Fields"
95
+ data[:coverage_entry_fields].each do |name, spec|
96
+ req = spec[:required] ? "required" : "optional"
97
+ type_text = Array(spec[:type]).join("|")
98
+ desc = spec[:description] ? " - #{spec[:description]}" : ""
99
+ lines << "- `#{name}` (#{type_text}, #{req})#{desc}"
100
+ end
101
+ end
82
102
  lines << ""
83
103
  lines << "## Value Summary Fields"
84
104
  data[:value_summary_fields].each do |name, spec|
@@ -25,7 +25,8 @@ module GenerateResultedHtml
25
25
  abort "missing #{source_path}"
26
26
  end
27
27
 
28
- raw_events = JSON.parse(File.read(events_path))
28
+ raw = JSON.parse(File.read(events_path))
29
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
29
30
  src = File.read(source_path)
30
31
  mode_info = resolve_mode_info(raw_events, collect_mode: collect_mode, max_samples: max_samples)
31
32
  normalized_ranges = normalize_ranges(ranges)
@@ -654,7 +655,8 @@ module GenerateResultedHtml
654
655
  end
655
656
 
656
657
  def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
657
- raw_events = JSON.parse(File.read(events_path))
658
+ raw = JSON.parse(File.read(events_path))
659
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
658
660
  render_all_from_events(
659
661
  raw_events,
660
662
  root: root,
@@ -56,11 +56,16 @@ module Lumitrace
56
56
  key_options: [
57
57
  { name: "--collect-mode", values: COLLECT_MODES, default: "last" },
58
58
  { name: "--max-samples", type: "Integer", default: 3, note: "Used by history mode." },
59
- { name: "--json[=PATH]", type: "bool|string", note: "Emit JSON output." },
60
- { name: "--html[=PATH]", type: "bool|string", note: "Emit HTML output." },
61
- { name: "--text[=PATH]", type: "bool|string", note: "Emit text output." },
59
+ { name: "-j, --json[=PATH]", type: "bool|string", note: "Emit JSON output." },
60
+ { name: "-h, --html[=PATH]", type: "bool|string", note: "Emit HTML output." },
61
+ { name: "-t, --text[=PATH]", type: "bool|string", note: "Emit text output." },
62
+ { name: "-g, --git-diff[=MODE]", type: "string", note: "Restrict instrumentation to diff hunks." },
62
63
  { name: "--range SPEC", type: "string", repeatable: true, note: "Restrict instrumentation to file ranges." },
63
- { name: "--git-diff[=MODE]", type: "string", note: "Restrict instrumentation to diff hunks." }
64
+ { name: "--git-diff-context N", type: "Integer", note: "Expand diff hunks by +/-N lines." },
65
+ { name: "--git-cmd PATH", type: "string", note: "Git executable for diff." },
66
+ { name: "--git-diff-no-untracked", type: "bool", note: "Exclude untracked files from diff." },
67
+ { name: "--root PATH", type: "string", note: "Root directory for instrumentation." },
68
+ { name: "--verbose[=LEVEL]", type: "Integer", note: "Verbose logs to stderr (level 1-3)." }
64
69
  ],
65
70
  outputs: [
66
71
  { kind: "json", default_path: "lumitrace_recorded.json" },
@@ -495,10 +495,42 @@ module RecordInstrument
495
495
 
496
496
  def self.dump_events_json(events, path = nil)
497
497
  path ||= File.expand_path("lumitrace_recorded.json", Dir.pwd)
498
- File.write(path, JSON.dump(events), perm: 0o600)
498
+ payload = {
499
+ version: 1,
500
+ events: events,
501
+ coverage: compute_coverage(events)
502
+ }
503
+ File.write(path, JSON.dump(payload), perm: 0o600)
499
504
  path
500
505
  end
501
506
 
507
+ def self.compute_coverage(events)
508
+ by_file = {}
509
+ events.each do |e|
510
+ file = e[:file] || e["file"]
511
+ next unless file
512
+ total = e[:total] || e["total"] || 0
513
+ start_line = e[:start_line] || e["start_line"]
514
+ next unless start_line
515
+
516
+ entry = (by_file[file] ||= { lines: {}, covered_lines: {} })
517
+ entry[:lines][start_line] = true
518
+ entry[:covered_lines][start_line] = true if total > 0
519
+ end
520
+
521
+ by_file.map do |file, entry|
522
+ total_lines = entry[:lines].size
523
+ covered_lines = entry[:covered_lines].size
524
+ pct = total_lines > 0 ? (covered_lines * 100.0 / total_lines).round(1) : 0.0
525
+ {
526
+ file: file,
527
+ total_lines: total_lines,
528
+ covered_lines: covered_lines,
529
+ coverage_percent: pct
530
+ }
531
+ end.sort_by { |e| e[:file] }
532
+ end
533
+
502
534
  def self.load_events_json(path)
503
535
  JSON.parse(File.read(path))
504
536
  end
@@ -686,25 +718,36 @@ module RecordInstrument
686
718
  events_from_ids
687
719
  end
688
720
 
721
+ KERNEL_INSPECT = ::Kernel.instance_method(:inspect)
722
+
723
+ def self.safe_inspect(v)
724
+ v.inspect
725
+ rescue ::NoMethodError
726
+ KERNEL_INSPECT.bind_call(v)
727
+ end
728
+
689
729
  def self.safe_value(v)
690
730
  case v
691
731
  when Numeric, TrueClass, FalseClass, NilClass
692
732
  v
693
733
  else
694
- s = v.inspect
734
+ s = safe_inspect(v)
695
735
  s.bytesize > 1000 ? s[0, 1000] + "..." : s
696
736
  end
697
737
  end
698
738
 
739
+ KERNEL_CLASS = ::Kernel.instance_method(:class)
740
+
699
741
  def self.value_type_name(v)
700
- name = v.class.name
701
- name && !name.empty? ? name : v.class.to_s
742
+ klass = KERNEL_CLASS.bind_call(v)
743
+ name = klass.name
744
+ name && !name.empty? ? name : klass.to_s
702
745
  end
703
746
 
704
747
  def self.summarize_value(v, type: nil)
705
748
  type ||= value_type_name(v)
706
749
  preview_limit = 120
707
- inspected = v.inspect
750
+ inspected = safe_inspect(v)
708
751
  if inspected.length > preview_limit
709
752
  {
710
753
  type: type,
@@ -9,8 +9,18 @@ module Lumitrace
9
9
  tool: "lumitrace",
10
10
  version: VERSION,
11
11
  json_top_level: {
12
- type: "array",
13
- items: "event"
12
+ type: "object",
13
+ fields: {
14
+ version: { type: "integer", description: "Schema version number." },
15
+ events: { type: "array", items: "event", description: "Traced expression events." },
16
+ coverage: { type: "array", items: "coverage_entry", description: "Per-file coverage summary." }
17
+ }
18
+ },
19
+ coverage_entry_fields: {
20
+ file: { type: "string", required: true, description: "Absolute source path." },
21
+ total_lines: { type: "integer", required: true, description: "Number of traced expression lines." },
22
+ covered_lines: { type: "integer", required: true, description: "Lines with total > 0." },
23
+ coverage_percent: { type: "float", required: true, description: "Covered / total * 100, rounded to 1 decimal." }
14
24
  },
15
25
  event_common_fields: {
16
26
  file: { type: "string", required: true, description: "Absolute source path." },
@@ -20,7 +30,7 @@ module Lumitrace
20
30
  end_col: { type: "integer", required: true },
21
31
  kind: { type: "string", required: true, enum: %w[expr arg] },
22
32
  name: { type: ["string", "null"], required: false, description: "Present for kind=arg." },
23
- total: { type: "integer", required: true, description: "Execution count." },
33
+ total: { type: "integer", required: true, description: "Execution count. 0 means the expression was never executed (uncovered)." },
24
34
  types: {
25
35
  type: "object",
26
36
  required: true,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lumitrace
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/lumitrace.rb CHANGED
@@ -13,6 +13,8 @@ require_relative "lumitrace/ai_docs"
13
13
 
14
14
  module Lumitrace
15
15
  class Error < StandardError; end
16
+ KERNEL_CLASS = ::Kernel.instance_method(:class)
17
+
16
18
  @atexit_registered = false
17
19
  @atexit_output_root = nil
18
20
  @atexit_ranges_by_file = nil
@@ -62,7 +64,7 @@ module Lumitrace
62
64
  def R(id, value)
63
65
  events_by_id = RecordInstrument.events_by_id
64
66
  entry = events_by_id[id]
65
- klass = value.class
67
+ klass = KERNEL_CLASS.bind_call(value)
66
68
  type = klass.name
67
69
  type = klass.to_s if type.nil? || type.empty?
68
70
  if entry
@@ -81,7 +83,7 @@ module Lumitrace
81
83
  def R(id, value)
82
84
  events_by_id = RecordInstrument.events_by_id
83
85
  entry = events_by_id[id]
84
- klass = value.class
86
+ klass = KERNEL_CLASS.bind_call(value)
85
87
  type = klass.name
86
88
  type = klass.to_s if type.nil? || type.empty?
87
89
  if entry
@@ -99,7 +101,7 @@ module Lumitrace
99
101
  def R(id, value)
100
102
  events_by_id = RecordInstrument.events_by_id
101
103
  entry = events_by_id[id]
102
- klass = value.class
104
+ klass = KERNEL_CLASS.bind_call(value)
103
105
  type = klass.name
104
106
  type = klass.to_s if type.nil? || type.empty?
105
107
  if entry
data/runv/index.html CHANGED
@@ -4,9 +4,9 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>LumiTrace Playground</title>
7
- <meta name="description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.5.0.">
7
+ <meta name="description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.">
8
8
  <meta property="og:title" content="LumiTrace Playground">
9
- <meta property="og:description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.5.0.">
9
+ <meta property="og:description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.">
10
10
  <meta property="og:type" content="website">
11
11
  <meta property="og:url" content="https://github.com/ko1/lumitrace/tree/master/runv">
12
12
  <meta property="og:see_also" content="https://github.com/ko1/lumitrace/tree/master/runv">
@@ -290,7 +290,7 @@
290
290
  <header>
291
291
  <div>
292
292
  <h1>LumiTrace Playground</h1>
293
- <p class="sub">Run Ruby in the browser and see traced values inline with lumitrace 0.5.0.</p>
293
+ <p class="sub">Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.</p>
294
294
  </div>
295
295
  </header>
296
296
 
@@ -936,10 +936,42 @@ module RecordInstrument
936
936
 
937
937
  def self.dump_events_json(events, path = nil)
938
938
  path ||= File.expand_path("lumitrace_recorded.json", Dir.pwd)
939
- File.write(path, JSON.dump(events), perm: 0o600)
939
+ payload = {
940
+ version: 1,
941
+ events: events,
942
+ coverage: compute_coverage(events)
943
+ }
944
+ File.write(path, JSON.dump(payload), perm: 0o600)
940
945
  path
941
946
  end
942
947
 
948
+ def self.compute_coverage(events)
949
+ by_file = {}
950
+ events.each do |e|
951
+ file = e[:file] || e["file"]
952
+ next unless file
953
+ total = e[:total] || e["total"] || 0
954
+ start_line = e[:start_line] || e["start_line"]
955
+ next unless start_line
956
+
957
+ entry = (by_file[file] ||= { lines: {}, covered_lines: {} })
958
+ entry[:lines][start_line] = true
959
+ entry[:covered_lines][start_line] = true if total > 0
960
+ end
961
+
962
+ by_file.map do |file, entry|
963
+ total_lines = entry[:lines].size
964
+ covered_lines = entry[:covered_lines].size
965
+ pct = total_lines > 0 ? (covered_lines * 100.0 / total_lines).round(1) : 0.0
966
+ {
967
+ file: file,
968
+ total_lines: total_lines,
969
+ covered_lines: covered_lines,
970
+ coverage_percent: pct
971
+ }
972
+ end.sort_by { |e| e[:file] }
973
+ end
974
+
943
975
  def self.load_events_json(path)
944
976
  JSON.parse(File.read(path))
945
977
  end
@@ -1127,25 +1159,36 @@ module RecordInstrument
1127
1159
  events_from_ids
1128
1160
  end
1129
1161
 
1162
+ KERNEL_INSPECT = ::Kernel.instance_method(:inspect)
1163
+
1164
+ def self.safe_inspect(v)
1165
+ v.inspect
1166
+ rescue ::NoMethodError
1167
+ KERNEL_INSPECT.bind_call(v)
1168
+ end
1169
+
1130
1170
  def self.safe_value(v)
1131
1171
  case v
1132
1172
  when Numeric, TrueClass, FalseClass, NilClass
1133
1173
  v
1134
1174
  else
1135
- s = v.inspect
1175
+ s = safe_inspect(v)
1136
1176
  s.bytesize > 1000 ? s[0, 1000] + "..." : s
1137
1177
  end
1138
1178
  end
1139
1179
 
1180
+ KERNEL_CLASS = ::Kernel.instance_method(:class)
1181
+
1140
1182
  def self.value_type_name(v)
1141
- name = v.class.name
1142
- name && !name.empty? ? name : v.class.to_s
1183
+ klass = KERNEL_CLASS.bind_call(v)
1184
+ name = klass.name
1185
+ name && !name.empty? ? name : klass.to_s
1143
1186
  end
1144
1187
 
1145
1188
  def self.summarize_value(v, type: nil)
1146
1189
  type ||= value_type_name(v)
1147
1190
  preview_limit = 120
1148
- inspected = v.inspect
1191
+ inspected = safe_inspect(v)
1149
1192
  if inspected.length > preview_limit
1150
1193
  {
1151
1194
  type: type,
@@ -1371,7 +1414,8 @@ module GenerateResultedHtml
1371
1414
  abort "missing #{source_path}"
1372
1415
  end
1373
1416
 
1374
- raw_events = JSON.parse(File.read(events_path))
1417
+ raw = JSON.parse(File.read(events_path))
1418
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
1375
1419
  src = File.read(source_path)
1376
1420
  mode_info = resolve_mode_info(raw_events, collect_mode: collect_mode, max_samples: max_samples)
1377
1421
  normalized_ranges = normalize_ranges(ranges)
@@ -2773,7 +2817,8 @@ end
2773
2817
  end
2774
2818
 
2775
2819
  def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
2776
- raw_events = JSON.parse(File.read(events_path))
2820
+ raw = JSON.parse(File.read(events_path))
2821
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
2777
2822
  render_all_from_events(
2778
2823
  raw_events,
2779
2824
  root: root,
@@ -499,6 +499,164 @@ class LumiTraceTest < Minitest::Test
499
499
  assert_equal "ruby script.rb arg1", payload[:meta][:command]
500
500
  end
501
501
 
502
+ def test_value_type_name_with_basic_object
503
+ klass = Class.new(BasicObject)
504
+ obj = klass.new
505
+ type = Lumitrace::RecordInstrument.value_type_name(obj)
506
+ refute_nil type
507
+ refute_empty type
508
+ end
509
+
510
+ def test_summarize_value_with_basic_object
511
+ klass = Class.new(BasicObject)
512
+ obj = klass.new
513
+ summary = Lumitrace::RecordInstrument.summarize_value(obj)
514
+ assert summary.is_a?(Hash)
515
+ assert summary.key?(:type)
516
+ assert summary.key?(:preview)
517
+ refute_nil summary[:type]
518
+ refute_empty summary[:type]
519
+ end
520
+
521
+ def test_R_with_basic_object_last_mode
522
+ with_record_instrument_state do
523
+ mod = Lumitrace::RecordInstrument
524
+ mod.instance_variable_set(:@events_by_id, [])
525
+ mod.instance_variable_set(:@loc_by_id, [])
526
+ mod.instance_variable_set(:@next_id, 0)
527
+ Lumitrace.install_collect_mode("last")
528
+
529
+ id = mod.register_location(
530
+ "a.rb",
531
+ { start_line: 1, start_col: 0, end_line: 1, end_col: 1 },
532
+ kind: :expr
533
+ )
534
+
535
+ klass = Class.new(BasicObject)
536
+ obj = klass.new
537
+ result = Lumitrace::R(id, obj)
538
+ assert_same obj, result
539
+
540
+ events = mod.events_from_ids
541
+ assert_equal 1, events.first[:total]
542
+ refute_empty events.first[:types]
543
+ end
544
+ end
545
+
546
+ def test_R_with_basic_object_history_mode
547
+ with_record_instrument_state do
548
+ mod = Lumitrace::RecordInstrument
549
+ mod.instance_variable_set(:@events_by_id, [])
550
+ mod.instance_variable_set(:@loc_by_id, [])
551
+ mod.instance_variable_set(:@next_id, 0)
552
+ Lumitrace.install_collect_mode("history")
553
+ mod.max_samples_per_expr = 3
554
+
555
+ id = mod.register_location(
556
+ "a.rb",
557
+ { start_line: 1, start_col: 0, end_line: 1, end_col: 1 },
558
+ kind: :expr
559
+ )
560
+
561
+ klass = Class.new(BasicObject)
562
+ Lumitrace::R(id, klass.new)
563
+ Lumitrace::R(id, 42)
564
+
565
+ events = mod.events_from_ids
566
+ assert_equal 2, events.first[:total]
567
+ assert_equal 2, events.first[:sampled_values].length
568
+ end
569
+ end
570
+
571
+ def test_R_with_basic_object_types_mode
572
+ with_record_instrument_state do
573
+ mod = Lumitrace::RecordInstrument
574
+ mod.instance_variable_set(:@events_by_id, [])
575
+ mod.instance_variable_set(:@loc_by_id, [])
576
+ mod.instance_variable_set(:@next_id, 0)
577
+ Lumitrace.install_collect_mode("types")
578
+
579
+ id = mod.register_location(
580
+ "a.rb",
581
+ { start_line: 1, start_col: 0, end_line: 1, end_col: 1 },
582
+ kind: :expr
583
+ )
584
+
585
+ klass = Class.new(BasicObject)
586
+ Lumitrace::R(id, klass.new)
587
+
588
+ events = mod.events_from_ids
589
+ assert_equal 1, events.first[:total]
590
+ refute_empty events.first[:types]
591
+ end
592
+ end
593
+
594
+ def test_compute_coverage
595
+ events = [
596
+ { file: "a.rb", start_line: 1, start_col: 0, end_line: 1, end_col: 5, total: 3 },
597
+ { file: "a.rb", start_line: 2, start_col: 0, end_line: 2, end_col: 5, total: 0 },
598
+ { file: "a.rb", start_line: 3, start_col: 0, end_line: 3, end_col: 5, total: 1 },
599
+ { file: "b.rb", start_line: 1, start_col: 0, end_line: 1, end_col: 5, total: 0 }
600
+ ]
601
+ coverage = Lumitrace::RecordInstrument.compute_coverage(events)
602
+ assert_equal 2, coverage.length
603
+
604
+ a = coverage.find { |c| c[:file] == "a.rb" }
605
+ assert_equal 3, a[:total_lines]
606
+ assert_equal 2, a[:covered_lines]
607
+ assert_equal 66.7, a[:coverage_percent]
608
+
609
+ b = coverage.find { |c| c[:file] == "b.rb" }
610
+ assert_equal 1, b[:total_lines]
611
+ assert_equal 0, b[:covered_lines]
612
+ assert_equal 0.0, b[:coverage_percent]
613
+ end
614
+
615
+ def test_dump_events_json_wrapped_format
616
+ Dir.mktmpdir do |dir|
617
+ events = [
618
+ { file: "a.rb", start_line: 1, start_col: 0, end_line: 1, end_col: 5, total: 1 }
619
+ ]
620
+ path = File.join(dir, "out.json")
621
+ Lumitrace::RecordInstrument.dump_events_json(events, path)
622
+ data = JSON.parse(File.read(path))
623
+
624
+ assert_equal 1, data["version"]
625
+ assert data.key?("events")
626
+ assert data.key?("coverage")
627
+ assert_equal 1, data["events"].length
628
+ assert_equal 1, data["coverage"].length
629
+ assert_equal 100.0, data["coverage"][0]["coverage_percent"]
630
+ end
631
+ end
632
+
633
+ def test_render_all_reads_wrapped_json
634
+ Dir.mktmpdir do |dir|
635
+ sample = File.join(dir, "sample.rb")
636
+ File.write(sample, "puts hi\n")
637
+ events_path = File.join(dir, "events.json")
638
+ payload = {
639
+ "version" => 1,
640
+ "events" => [
641
+ {
642
+ "file" => sample,
643
+ "start_line" => 1,
644
+ "start_col" => 0,
645
+ "end_line" => 1,
646
+ "end_col" => 5,
647
+ "sampled_values" => ["ok"],
648
+ "total" => 1
649
+ }
650
+ ],
651
+ "coverage" => []
652
+ }
653
+ File.write(events_path, JSON.dump(payload))
654
+
655
+ html = Lumitrace::GenerateResultedHtml.render_all(events_path, root: dir)
656
+ assert_includes html, "sample.rb"
657
+ end
658
+ end
659
+
502
660
  def test_env_range_parsing
503
661
  with_env("LUMITRACE_RANGE" => "a.rb:1-3,5-6;b.rb", "LUMITRACE_COLLECT_MODE" => "types") do
504
662
  env = Lumitrace.resolve_env_options
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lumitrace
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Koichi Sasada