coverband 6.2.0 → 6.2.2

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +10 -2
  3. data/.gitignore +2 -1
  4. data/README.md +119 -2
  5. data/Rakefile +9 -1
  6. data/changes.md +16 -0
  7. data/lib/coverband/collectors/abstract_tracker.rb +17 -1
  8. data/lib/coverband/collectors/delta.rb +21 -7
  9. data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
  10. data/lib/coverband/collectors/view_tracker.rb +11 -1
  11. data/lib/coverband/configuration.rb +14 -0
  12. data/lib/coverband/mcp/http_handler.rb +27 -26
  13. data/lib/coverband/reporters/web.rb +28 -2
  14. data/lib/coverband/utils/configuration_template.rb +4 -0
  15. data/lib/coverband/utils/file_list.rb +1 -0
  16. data/lib/coverband/utils/html_formatter.rb +7 -2
  17. data/lib/coverband/utils/tasks.rb +18 -0
  18. data/lib/coverband/version.rb +1 -1
  19. data/lib/coverband.rb +3 -2
  20. data/public/application.css +27 -0
  21. data/public/application.js +246 -0
  22. data/public/dead_code_session.js +173 -0
  23. data/test/benchmarks/benchmark.rake +18 -10
  24. data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
  25. data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
  26. data/test/coverband/collectors/delta_test.rb +17 -0
  27. data/test/coverband/collectors/query_burst_tracker_test.rb +77 -0
  28. data/test/coverband/collectors/view_tracker_test.rb +27 -0
  29. data/test/coverband/configuration_test.rb +12 -0
  30. data/test/coverband/mcp/http_handler_test.rb +58 -46
  31. data/test/coverband/reporters/web_test.rb +67 -0
  32. data/test/coverband/track_key_test.rb +9 -0
  33. data/test/forked/rails_route_tracker_stack_test.rb +1 -1
  34. data/test/forked/rails_view_tracker_stack_test.rb +1 -1
  35. data/test/integration/mcp_integration_test.rb +3 -3
  36. data/test/javascript/dead_code_session.test.mjs +123 -0
  37. data/views/abstract_tracker.erb +49 -2
  38. data/views/layout.erb +1 -0
  39. data/views/nav.erb +5 -0
  40. metadata +7 -1
@@ -10,6 +10,39 @@ module Coverband
10
10
  class WebTest < Minitest::Test
11
11
  include Rack::Test::Methods
12
12
 
13
+ class FakeViewsTracker
14
+ REPORT_ROUTE = "views_tracker"
15
+ TITLE = "Views"
16
+
17
+ attr_reader :used_keys
18
+
19
+ def initialize(used_keys:)
20
+ @used_keys = used_keys
21
+ end
22
+
23
+ def route
24
+ REPORT_ROUTE
25
+ end
26
+
27
+ def title
28
+ TITLE
29
+ end
30
+
31
+ def unused_keys
32
+ []
33
+ end
34
+
35
+ def tracking_since
36
+ "N/A"
37
+ end
38
+
39
+ def clear_key!(_key)
40
+ end
41
+
42
+ def reset_recordings
43
+ end
44
+ end
45
+
13
46
  def app
14
47
  Coverband::Reporters::Web.new
15
48
  end
@@ -55,6 +88,40 @@ module Coverband
55
88
  get "/unknown.js"
56
89
  assert last_response.not_found?
57
90
  end
91
+
92
+ test "views tracker defaults to sorting used keys by last activity" do
93
+ tracker = FakeViewsTracker.new(
94
+ used_keys: {
95
+ "app/views/a_first.html.erb" => "100",
96
+ "app/views/z_latest.html.erb" => "200"
97
+ }
98
+ )
99
+
100
+ Coverband.configuration.trackers = [tracker]
101
+ get "/views_tracker"
102
+
103
+ assert last_response.ok?
104
+ first_index = last_response.body.index("app/views/a_first.html.erb")
105
+ latest_index = last_response.body.index("app/views/z_latest.html.erb")
106
+ assert latest_index < first_index
107
+ end
108
+
109
+ test "views tracker supports alpha sort toggle" do
110
+ tracker = FakeViewsTracker.new(
111
+ used_keys: {
112
+ "app/views/z_latest.html.erb" => "200",
113
+ "app/views/a_first.html.erb" => "100"
114
+ }
115
+ )
116
+
117
+ Coverband.configuration.trackers = [tracker]
118
+ get "/views_tracker?used_sort=alpha"
119
+
120
+ assert last_response.ok?
121
+ first_index = last_response.body.index("app/views/a_first.html.erb")
122
+ latest_index = last_response.body.index("app/views/z_latest.html.erb")
123
+ assert first_index < latest_index
124
+ end
58
125
  end
59
126
  end
60
127
 
@@ -51,6 +51,15 @@ class TrackKeyTest < Minitest::Test
51
51
  assert Coverband.track_key(:routes_tracker, "index#show")
52
52
  end
53
53
 
54
+ test "track_key with query_burst_tracker" do
55
+ mock_query_burst_tracker = mock
56
+ payload = {key: "controller:users#index", queries: 42, sql_time_ms: 155.2}
57
+ mock_query_burst_tracker.expects(:track_key).with(payload).returns(true)
58
+ Coverband.configuration.expects(:query_burst_tracker).returns(mock_query_burst_tracker)
59
+
60
+ assert Coverband.track_key(:query_burst_tracker, payload)
61
+ end
62
+
54
63
  test "track_key logs error when tracking fails" do
55
64
  mock_logger = mock
56
65
  mock_logger.expects(:error).with(regexp_matches(/Failed to track key/))
@@ -38,7 +38,7 @@ class RailsRoutesWithoutConfigStackTest < Minitest::Test
38
38
  def shutdown_server
39
39
  if File.exist?("/tmp/testrack.pid")
40
40
  pid = `cat /tmp/testrack.pid`&.strip&.to_i
41
- Process.kill("HUP", pid)
41
+ Process.kill("TERM", pid)
42
42
  sleep 1
43
43
  end
44
44
  end
@@ -37,7 +37,7 @@ class RailsWithoutConfigStackTest < Minitest::Test
37
37
  def shutdown_server
38
38
  if File.exist?("/tmp/testrack.pid")
39
39
  pid = `cat /tmp/testrack.pid`&.strip&.to_i
40
- Process.kill("HUP", pid)
40
+ Process.kill("TERM", pid)
41
41
  sleep 1
42
42
  end
43
43
  end
@@ -84,9 +84,9 @@ if defined?(Coverband::MCP)
84
84
  test "HTTP handler integrates with MCP server" do
85
85
  handler = Coverband::MCP::HttpHandler.new
86
86
 
87
- # Verify handler can create and use MCP server
88
- server = handler.send(:mcp_server)
89
- assert_instance_of Coverband::MCP::Server, server
87
+ # Verify handler can create and use transport with MCP server
88
+ transport = handler.send(:transport)
89
+ assert_instance_of ::MCP::Server::Transports::StreamableHTTPTransport, transport
90
90
 
91
91
  # Verify handler responds to rack interface
92
92
  assert_respond_to handler, :call
@@ -0,0 +1,123 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createRequire } from "node:module";
4
+ const require = createRequire(import.meta.url);
5
+ const D = require("../../public/dead_code_session.js");
6
+
7
+ test("parseState returns empty state on garbage", () => {
8
+ assert.deepEqual(D.parseState(null), D.emptyState());
9
+ assert.deepEqual(D.parseState("{not json"), D.emptyState());
10
+ assert.deepEqual(D.parseState('{"hiddenPaths": "nope"}').hiddenPaths, []);
11
+ });
12
+
13
+ test("parseState keeps valid entries and normalizes marks", () => {
14
+ const s = D.parseState(JSON.stringify({
15
+ hiddenPaths: ["app/", 5, "lib/foo.rb"],
16
+ markedPaths: [{ path: "old/" }, { nope: true }],
17
+ hideRuntimeUsed: true
18
+ }));
19
+ assert.deepEqual(s.hiddenPaths, ["app/", "lib/foo.rb"]);
20
+ assert.deepEqual(s.markedPaths, [{ path: "old/", comment: "", markedAt: "" }]);
21
+ assert.equal(s.hideRuntimeUsed, true);
22
+ });
23
+
24
+ test("pathMatches: folder prefix vs exact file", () => {
25
+ assert.ok(D.pathMatches("app/models/", "app/models/foo.rb"));
26
+ assert.ok(!D.pathMatches("app/models/", "app/models2/foo.rb"));
27
+ assert.ok(D.pathMatches("app/foo.rb", "app/foo.rb"));
28
+ assert.ok(!D.pathMatches("app/foo.rb", "app/foo.rb.bak"));
29
+ });
30
+
31
+ test("isExcluded combines hides, marks, and runtime rule", () => {
32
+ const s = D.emptyState();
33
+ D.addHidden(s, "app/hidden/");
34
+ D.addMark(s, "lib/dead.rb", "bye", "2026-08-07T00:00:00Z");
35
+ assert.ok(D.isExcluded(s, "app/hidden/x.rb", 0));
36
+ assert.ok(D.isExcluded(s, "lib/dead.rb", 0));
37
+ assert.ok(!D.isExcluded(s, "app/live.rb", 12.5));
38
+ s.hideRuntimeUsed = true;
39
+ assert.ok(D.isExcluded(s, "app/live.rb", 12.5));
40
+ assert.ok(!D.isExcluded(s, "app/unused.rb", 0));
41
+ assert.ok(!D.isExcluded(s, "app/unknown.rb", null));
42
+ });
43
+
44
+ test("addHidden dedupes; removeHidden removes; clearHides resets toggle", () => {
45
+ const s = D.emptyState();
46
+ D.addHidden(s, "app/");
47
+ D.addHidden(s, "app/");
48
+ assert.deepEqual(s.hiddenPaths, ["app/"]);
49
+ D.removeHidden(s, "app/");
50
+ assert.deepEqual(s.hiddenPaths, []);
51
+ s.hideRuntimeUsed = true;
52
+ D.addHidden(s, "x.rb");
53
+ D.clearHides(s);
54
+ assert.deepEqual(s.hiddenPaths, []);
55
+ assert.equal(s.hideRuntimeUsed, false);
56
+ });
57
+
58
+ test("addMark replaces same path; updateComment; removeMark; clearMarks", () => {
59
+ const s = D.emptyState();
60
+ D.addMark(s, "a.rb", "one", "t1");
61
+ D.addMark(s, "a.rb", "two", "t2");
62
+ assert.deepEqual(s.markedPaths, [{ path: "a.rb", comment: "two", markedAt: "t2" }]);
63
+ D.updateComment(s, "a.rb", "three");
64
+ assert.equal(s.markedPaths[0].comment, "three");
65
+ D.removeMark(s, "a.rb");
66
+ assert.deepEqual(s.markedPaths, []);
67
+ D.addMark(s, "b.rb", "", "t");
68
+ D.clearMarks(s);
69
+ assert.deepEqual(s.markedPaths, []);
70
+ });
71
+
72
+ test("toCSV escapes per RFC 4180", () => {
73
+ const csv = D.toCSV([
74
+ { path: "app/a.rb", comment: 'has "quotes", commas\nand newline', markedAt: "2026-08-07T01:02:03Z" },
75
+ { path: "lib/", comment: "", markedAt: "" }
76
+ ]);
77
+ assert.equal(csv,
78
+ 'path,comment,marked_at\r\n' +
79
+ 'app/a.rb,"has ""quotes"", commas\nand newline",2026-08-07T01:02:03Z\r\n' +
80
+ 'lib/,,\r\n');
81
+ });
82
+
83
+ test("segmentPrefixes splits path into cumulative prefixes", () => {
84
+ assert.deepEqual(D.segmentPrefixes("app/models/foo.rb"), [
85
+ { label: "app", prefix: "app/", isFile: false },
86
+ { label: "models", prefix: "app/models/", isFile: false },
87
+ { label: "foo.rb", prefix: "app/models/foo.rb", isFile: true }
88
+ ]);
89
+ assert.deepEqual(D.segmentPrefixes("foo.rb"), [{ label: "foo.rb", prefix: "foo.rb", isFile: true }]);
90
+ });
91
+
92
+ test("isExcludedIgnoringMarks applies hides and runtime rule but never marks", () => {
93
+ const s = D.emptyState();
94
+ D.addHidden(s, "app/hidden/");
95
+ D.addMark(s, "lib/dead.rb", "bye", "2026-08-07T00:00:00Z");
96
+ assert.ok(D.isExcludedIgnoringMarks(s, "app/hidden/x.rb", 0));
97
+ assert.ok(!D.isExcludedIgnoringMarks(s, "lib/dead.rb", 0), "marks alone must not exclude");
98
+ s.hideRuntimeUsed = true;
99
+ assert.ok(D.isExcludedIgnoringMarks(s, "app/live.rb", 12.5));
100
+ assert.ok(!D.isExcludedIgnoringMarks(s, "app/unused.rb", 0));
101
+ });
102
+
103
+ test("extractPath pulls the title attribute out of a rendered cell", () => {
104
+ assert.equal(D.extractPath('<a class="src_link" title="app/models/foo.rb">foo.rb</a>'), "app/models/foo.rb");
105
+ assert.equal(D.extractPath("<span>no title here</span>"), "");
106
+ assert.equal(D.extractPath(42), "");
107
+ });
108
+
109
+ test("rowRuntime parses the runtime column, null when not numeric", () => {
110
+ assert.equal(D.rowRuntime(["path", "link", "12.5"]), 12.5);
111
+ assert.equal(D.rowRuntime(["path", "link", "0"]), 0);
112
+ assert.equal(D.rowRuntime(["path", "link", "-"]), null);
113
+ assert.equal(D.rowRuntime(["path", "link", undefined]), null);
114
+ });
115
+
116
+ test("escapeHtml escapes the five HTML-significant characters", () => {
117
+ assert.equal(D.escapeHtml('<b>"Tom" & Jerry</b>'), "&lt;b&gt;&quot;Tom&quot; &amp; Jerry&lt;/b&gt;");
118
+ assert.equal(D.escapeHtml("plain"), "plain");
119
+ });
120
+
121
+ test("csvFilename embeds the ISO date", () => {
122
+ assert.equal(D.csvFilename(new Date("2026-08-07T15:04:05Z")), "coverband-dead-code-2026-08-07.csv");
123
+ });
@@ -27,9 +27,23 @@
27
27
  </ul>
28
28
 
29
29
  <h2>Used <%= tracker.title %>: (<%= tracker.used_keys.length %>)</h2>
30
- <p>These <%= tracker.title %> have been rendered at least once</p>
30
+ <p>
31
+ These <%= tracker.title %> have been rendered at least once.
32
+ Sort:
33
+ <% if used_keys_sort == "last_active" %>
34
+ <strong>Last Active</strong>
35
+ <% else %>
36
+ <a href='<%= "#{base_path}/#{tracker.route}?used_sort=last_active" %>'>Last Active</a>
37
+ <% end %>
38
+ |
39
+ <% if used_keys_sort == "alpha" %>
40
+ <strong>Alphabetical</strong>
41
+ <% else %>
42
+ <a href='<%= "#{base_path}/#{tracker.route}?used_sort=alpha" %>'>Alphabetical</a>
43
+ <% end %>
44
+ </p>
31
45
  <ul>
32
- <% tracker.used_keys.each_pair do |key, time_at| %>
46
+ <% (sorted_used_keys || tracker.used_keys).each_pair do |key, time_at| %>
33
47
  <li class="used-keys">
34
48
  <%= key %>
35
49
  <span class="last_seen_at">last activity recorded <%= Time.at(time_at.to_i)%></span>
@@ -39,6 +53,39 @@
39
53
  </li>
40
54
  <% end %>
41
55
  </ul>
56
+
57
+ <% if tracker.respond_to?(:used_key_stats) %>
58
+ <% key_stats = tracker.used_key_stats %>
59
+ <% if key_stats.any? %>
60
+ <h3><%= tracker.title %> Stats</h3>
61
+ <table class="file_list sorted">
62
+ <thead>
63
+ <tr>
64
+ <th>Key</th>
65
+ <th>Requests</th>
66
+ <th>Total SQL Calls</th>
67
+ <th>Total SQL ms</th>
68
+ <th>Max SQL Calls</th>
69
+ <th>Max SQL ms</th>
70
+ <th>Threshold Hits</th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ <% key_stats.each_pair do |key, stats| %>
75
+ <tr>
76
+ <td><%= key %></td>
77
+ <td><%= stats["requests"].to_i %></td>
78
+ <td><%= stats["total_queries"].to_i %></td>
79
+ <td><%= stats["total_sql_time_ms"].to_f.round(2) %></td>
80
+ <td><%= stats["max_queries"].to_i %></td>
81
+ <td><%= stats["max_sql_time_ms"].to_f.round(2) %></td>
82
+ <td><%= stats["threshold_hits"].to_i %></td>
83
+ </tr>
84
+ <% end %>
85
+ </tbody>
86
+ </table>
87
+ <% end %>
88
+ <% end %>
42
89
  </div>
43
90
  <div id="footer">
44
91
  Generated by <a href="http://github.com/danmayer/coverband">Coverband</a> v<%= Coverband::VERSION %>
data/views/layout.erb CHANGED
@@ -4,6 +4,7 @@
4
4
  <title>Coverband: <%= Coverband::VERSION %></title>
5
5
  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
6
6
  <script src='<%= assets_path('dependencies.js') %>' type='text/javascript'></script>
7
+ <script src='<%= assets_path('dead_code_session.js') %>' type='text/javascript'></script>
7
8
  <script src='<%= assets_path('application.js') %>' type='text/javascript'></script>
8
9
  <link href='<%= assets_path('application.css') %>' media='screen, projection, print' rel='stylesheet' type='text/css'>
9
10
  <link rel="shortcut icon" type="image/png" href="<%= assets_path("favicon_#{coverage_css_class(result.source_files.covered_percent)}.png") %>" />
data/views/nav.erb CHANGED
@@ -37,6 +37,11 @@
37
37
  <li class='<%= nav_options[:active_link] == 'routes_tracker' ? 'active' : '' %>'>
38
38
  <a href='<%= base_path %>routes_tracker'>Route Tracker</a>
39
39
  </li>
40
+ <% end %>
41
+ <% if Coverband.configuration.track_query_bursts %>
42
+ <li class='<%= nav_options[:active_link] == 'query_bursts_tracker' ? 'active' : '' %>'>
43
+ <a href='<%= base_path %>query_bursts_tracker'>Query Burst Tracker</a>
44
+ </li>
40
45
  <% end %>
41
46
  <% if Coverband.configuration.track_translations %>
42
47
  <li class='<%= nav_options[:active_link] == 'translations_tracker' ? 'active' : '' %>'>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coverband
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.2.0
4
+ version: 6.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Mayer
@@ -365,6 +365,7 @@ files:
365
365
  - lib/coverband/collectors/abstract_tracker.rb
366
366
  - lib/coverband/collectors/coverage.rb
367
367
  - lib/coverband/collectors/delta.rb
368
+ - lib/coverband/collectors/query_burst_tracker.rb
368
369
  - lib/coverband/collectors/route_tracker.rb
369
370
  - lib/coverband/collectors/translation_tracker.rb
370
371
  - lib/coverband/collectors/view_tracker.rb
@@ -421,6 +422,7 @@ files:
421
422
  - public/colorbox/controls.png
422
423
  - public/colorbox/loading.gif
423
424
  - public/colorbox/loading_background.png
425
+ - public/dead_code_session.js
424
426
  - public/dependencies.js
425
427
  - public/favicon.png
426
428
  - public/favicon_green.png
@@ -446,6 +448,7 @@ files:
446
448
  - test/benchmarks/benchmark.rake
447
449
  - test/benchmarks/benchmark_delta.rb
448
450
  - test/benchmarks/benchmark_file_list.rb
451
+ - test/benchmarks/benchmark_file_list_covered_lines.rb
449
452
  - test/benchmarks/benchmark_unused_keys.rb
450
453
  - test/benchmarks/coverage_fork.sh
451
454
  - test/benchmarks/dog.rb
@@ -460,8 +463,10 @@ files:
460
463
  - test/coverband/adapters/redis_store_test.rb
461
464
  - test/coverband/adapters/web_service_store_test.rb
462
465
  - test/coverband/at_exit_test.rb
466
+ - test/coverband/benchmarks/benchmark_memory_leak_check_test.rb
463
467
  - test/coverband/collectors/coverage_test.rb
464
468
  - test/coverband/collectors/delta_test.rb
469
+ - test/coverband/collectors/query_burst_tracker_test.rb
465
470
  - test/coverband/collectors/route_tracker_test.rb
466
471
  - test/coverband/collectors/translation_tracker_test.rb
467
472
  - test/coverband/collectors/view_tracker_test.rb
@@ -527,6 +532,7 @@ files:
527
532
  - test/integration/full_stack_send_deferred_eager_test.rb
528
533
  - test/integration/full_stack_test.rb
529
534
  - test/integration/mcp_integration_test.rb
535
+ - test/javascript/dead_code_session.test.mjs
530
536
  - test/jruby_check.rb
531
537
  - test/rails7_dummy/Rakefile
532
538
  - test/rails7_dummy/app/controllers/dummy_controller.rb