cover_rage 1.1.1 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e35c3c64110ebafa0432d4ac37f2d05b19a552df8505263f751f4fb85a841525
4
- data.tar.gz: 9cd16923a839fd2ddcd6f5b585d28bfc19ab40a466d810ac8a93378bf5c13b02
3
+ metadata.gz: 00ad4585a873585456f2c9948a88bcab8d54438b5c952a7fdb03a0e411763e3e
4
+ data.tar.gz: 231662f71294e8e983e7632c8ad530b3c18752f2e6d4d0a5d2ea3ea2de0edc90
5
5
  SHA512:
6
- metadata.gz: b1b158a2dc50611a60482aeae2f448c9f4f8ae59f3d1ebb6ecd29bc03fea38a82a934b4b84906a4f331a7b293ccfcb4780ab7d956fe24015c42c54a8d4a52cdc
7
- data.tar.gz: b06bdca3118bf5d80f7225e4792d258a18ef17a27f9255e0993a0e9523aab81e1aa88051b6c6670241ea688bfd10df10118ce9934e84e1c4379d4fcae02b7282
6
+ metadata.gz: '048eb40f6ed0df7c75e4f353ce17fa516345a2832d6b25497453d4bb94aa302197e8e57c5fdbe50444d3a35da91bf641d1a0bbbd2b8d911cf457988016588932'
7
+ data.tar.gz: 587134f993d40afb1c4b9242e030aac02ea886436a1e3912413630f1e7f4e97a403b1b2693a4af8907df23e9a4a8f91e7fa2f782089fa7609fe997ed6e45fa88
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CoverRage
4
- Record = Data.define(:path, :revision, :source, :execution_count) do
4
+ Record = Data.define(:path, :revision, :source, :execution_count, :last_executed_at) do
5
5
  def self.merge(existing, current)
6
6
  records_to_save = []
7
7
  current.each do |record|
@@ -20,6 +20,13 @@ module CoverRage
20
20
  with(
21
21
  execution_count: execution_count.map.with_index do |item, index|
22
22
  item.nil? ? nil : item + other.execution_count[index]
23
+ end,
24
+ last_executed_at: last_executed_at.map.with_index do |item, index|
25
+ if item.nil? && other.last_executed_at[index].nil? then nil
26
+ elsif item.nil? then other.last_executed_at[index]
27
+ elsif other.last_executed_at[index].nil? then item
28
+ else [item, other.last_executed_at[index]].max
29
+ end
23
30
  end
24
31
  )
25
32
  end
@@ -42,11 +42,15 @@ module CoverRage
42
42
  relative_path = filepath.delete_prefix(@path_prefix)
43
43
  revision, source = read_file_with_revision(filepath)
44
44
 
45
+ now = Time.now.to_i
46
+ last_executed_at = execution_count.map { |c| c&.positive? ? now : nil }
47
+
45
48
  records << Record.new(
46
49
  path: relative_path,
47
50
  revision:,
48
51
  source:,
49
- execution_count:
52
+ execution_count:,
53
+ last_executed_at:
50
54
  )
51
55
  end
52
56
  return unless records.any?
@@ -2,7 +2,6 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
6
  <title>CoverRage</title>
8
7
  <link
@@ -15,43 +14,41 @@
15
14
  display: inline-block;
16
15
  counter-increment: line_number;
17
16
  }
18
-
19
- .green {
20
- background-color: rgb(221, 255, 221);
21
- }
22
-
23
- .red {
24
- background-color: rgb(255, 212, 212);
25
- }
26
-
17
+ .green { background-color: rgb(221, 255, 221); }
18
+ .blue { background-color: rgb(221, 221, 255); }
19
+ .red { background-color: rgb(255, 212, 212); }
27
20
  .number {
28
21
  display: inline-block;
29
22
  text-align: right;
30
- margin-right: 0.5em;
31
23
  background-color: lightgray;
32
24
  padding: 0 0.5em 0 1.5em;
33
25
  }
34
-
26
+ .timestamp {
27
+ display: inline-block;
28
+ text-align: right;
29
+ margin-right: 0.5em;
30
+ background-color: lightgray;
31
+ padding: 0 0.5em;
32
+ }
35
33
  .nav {
36
34
  display: flex;
37
35
  list-style: none;
38
36
  padding-left: 0;
39
- }
40
- .nav > * {
41
- margin-left: 8px;
37
+ gap: 8px;
42
38
  }
43
39
  </style>
44
40
  </head>
45
41
  <body>
46
42
  <main id="main"></main>
47
43
 
48
- <div style="display: none">
49
- <div id="page-index">
44
+ <template id="tmpl-index">
45
+ <div>
50
46
  <nav>
51
47
  <ul class="nav">
52
48
  <li><a href="#/">Index</a></li>
53
49
  </ul>
54
50
  </nav>
51
+ <label>stale threshold <input id="stale-threshold" type="number" min="0" value="30"> days</label>
55
52
  <table>
56
53
  <thead>
57
54
  <tr>
@@ -60,156 +57,165 @@
60
57
  <th>relevancy</th>
61
58
  <th>hit</th>
62
59
  <th>miss</th>
63
- <th>coverage (%)</th>
60
+ <th>stale</th>
61
+ <th class="sortable" data-sort="coverage" style="cursor:pointer">coverage (%) ▼</th>
62
+ <th class="sortable" data-sort="staleness" style="cursor:pointer">staleness (%)</th>
64
63
  </tr>
65
64
  </thead>
66
65
  <tbody></tbody>
67
66
  </table>
68
67
  </div>
69
- <div id="page-file">
68
+ </template>
69
+
70
+ <template id="tmpl-file">
71
+ <div>
70
72
  <nav>
71
73
  <ul class="nav">
72
74
  <li><a href="#/">Index</a></li>
73
- <li>></li>
75
+ <li>&gt;</li>
74
76
  <li id="title"></li>
75
77
  </ul>
76
78
  </nav>
79
+ <label>stale threshold <input id="stale-threshold" type="number" min="0" value="30"> days</label>
77
80
  <pre><code id="code"></code></pre>
78
81
  </div>
79
- </div>
82
+ </template>
80
83
 
81
84
  <script id="records" type="application/json"><%= records %></script>
82
85
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
83
86
  <script>
87
+ const records = JSON.parse(document.getElementById("records").textContent);
84
88
  const main = document.getElementById("main");
85
- const page_index = document.getElementById("page-index");
86
- const title = document.createTextNode("");
87
- document.getElementById("title").appendChild(title);
88
- const code = document.getElementById("code");
89
- const page_file = document.getElementById("page-file");
90
- const records = JSON.parse(
91
- document.getElementById("records").childNodes[0].nodeValue
92
- );
93
89
 
94
- const route = (path) => {
95
- if (path.startsWith("/file/")) {
96
- const filepath = path.slice(6);
97
- if (records.find((record) => record.path === filepath))
98
- return { page: "file", path: filepath };
90
+ function route() {
91
+ const hash = window.location.hash.slice(1);
92
+ if (hash.startsWith("/file/")) {
93
+ const path = hash.slice(6);
94
+ const record = records.find((r) => r.path === path);
95
+ if (record) return { page: "file", path, record };
99
96
  }
100
-
101
97
  return { page: "index" };
102
- };
98
+ }
103
99
 
104
- const render = (routing) => {
105
- let page;
106
- if (routing.page === "file") {
107
- const record = records.find(({ path }) => path === routing.path);
108
- page = page_file;
109
- title.nodeValue = routing.path;
110
- const digit_width =
111
- Math.floor(Math.log10(Math.max(...record.execution_count))) + 1;
112
- code.innerHTML = hljs
113
- .highlight(
114
- records.find(({ path }) => path === routing.path).source,
115
- {
116
- language: "ruby",
117
- }
118
- )
119
- .value.split("\n")
120
- .map((line, index) => {
121
- const value = record.execution_count[index];
122
- let color = "";
123
- if (typeof value === "number")
124
- color = value > 0 ? "green" : "red";
125
- const number = typeof value === "number" ? value : "-";
126
- return `<span class="line ${color}"><span class="number">${number
127
- .toString()
128
- .padStart(digit_width, " ")}</span>${line}</span>`;
129
- })
130
- .join("\n");
100
+ function cloneTemplate(id) {
101
+ return document.getElementById(id).content.cloneNode(true).firstElementChild;
102
+ }
103
+
104
+ function formatDate(posixTime) {
105
+ if (typeof posixTime !== "number") return "-";
106
+ return new Date(posixTime * 1000).toISOString().slice(0, 10);
107
+ }
108
+
109
+ function colorClass(count, lastExecutedAt, staleThresholdDays) {
110
+ if (typeof count !== "number") return "";
111
+ if (count === 0) return "red";
112
+ if (typeof lastExecutedAt === "number") {
113
+ const ageDays = (Date.now() / 1000 - lastExecutedAt) / 86400;
114
+ if (ageDays > staleThresholdDays) return "blue";
131
115
  }
132
- if (routing.page === "index") {
133
- page = page_index;
116
+ return "green";
117
+ }
118
+
119
+ function summarize({ path, execution_count, last_executed_at }, staleThresholdDays) {
120
+ let hit = 0, miss = 0, relevancy = 0, stale = 0;
121
+ const staleCutoff = Date.now() / 1000 - staleThresholdDays * 86400;
122
+ for (let i = 0; i < execution_count.length; i++) {
123
+ const count = execution_count[i];
124
+ if (count !== null) {
125
+ relevancy++;
126
+ if (count > 0) {
127
+ hit++;
128
+ const t = last_executed_at[i];
129
+ if (typeof t === "number" && t < staleCutoff) stale++;
130
+ } else {
131
+ miss++;
132
+ }
133
+ }
134
134
  }
135
- if (main.childNodes.length === 0) main.appendChild(page);
136
- else main.replaceChild(page, main.childNodes[0]);
137
- };
138
- const createTdTextNode = (text) => {
139
- const td = document.createElement("td");
140
- td.appendChild(document.createTextNode(text));
141
- return td;
142
- };
135
+ return {
136
+ path,
137
+ lines: execution_count.length,
138
+ relevancy,
139
+ hit,
140
+ miss,
141
+ stale,
142
+ coverage: hit / relevancy,
143
+ staleness: stale / relevancy,
144
+ };
145
+ }
143
146
 
144
- page_index.querySelector("tbody").appendChild(
145
- records
146
- .map(({ path, revision, source, execution_count }) => {
147
- const hit = execution_count.reduce(
148
- (accumulator, current) =>
149
- current > 0 ? accumulator + 1 : accumulator,
150
- 0
151
- );
152
- const miss = execution_count.reduce(
153
- (accumulator, current) =>
154
- current === 0 ? accumulator + 1 : accumulator,
155
- 0
156
- );
157
- const relevancy = execution_count.reduce(
158
- (accumulator, current) =>
159
- current !== null ? accumulator + 1 : accumulator,
160
- 0
161
- );
162
- const coverage = hit / relevancy;
163
- return {
164
- path,
165
- lines: execution_count.length,
166
- hit,
167
- miss,
168
- relevancy,
169
- coverage,
170
- };
171
- })
172
- .sort(({ coverage: a }, { coverage: b }) => {
173
- if (isNaN(b)) return -1;
174
- if (isNaN(a)) return 1;
175
- return a - b;
147
+ let staleThresholdDays = 30;
148
+ let sortKey = "coverage";
149
+ let sortAsc = true;
150
+
151
+ function getStaleThreshold(el) {
152
+ const input = el.querySelector("#stale-threshold");
153
+ input.value = staleThresholdDays;
154
+ input.addEventListener("change", () => {
155
+ staleThresholdDays = Number(input.value) || 30;
156
+ render();
157
+ });
158
+ return staleThresholdDays;
159
+ }
160
+
161
+ function renderIndex() {
162
+ const el = cloneTemplate("tmpl-index");
163
+ const staleThresholdDays = getStaleThreshold(el);
164
+ const tbody = el.querySelector("tbody");
165
+ el.querySelectorAll(".sortable").forEach((th) => {
166
+ const key = th.dataset.sort;
167
+ th.textContent = th.textContent.replace(/ [▲▼]$/, "") + (sortKey === key ? (sortAsc ? " ▲" : " ▼") : "");
168
+ th.addEventListener("click", () => {
169
+ if (sortKey === key) { sortAsc = !sortAsc; } else { sortKey = key; sortAsc = true; }
170
+ render();
171
+ });
172
+ });
173
+ const rows = records.map((r) => summarize(r, staleThresholdDays)).sort((a, b) => {
174
+ const av = a[sortKey], bv = b[sortKey];
175
+ if (isNaN(bv)) return -1;
176
+ if (isNaN(av)) return 1;
177
+ return sortAsc ? av - bv : bv - av;
178
+ });
179
+ for (const { path, lines, relevancy, hit, miss, stale, coverage, staleness } of rows) {
180
+ const tr = tbody.insertRow();
181
+ tr.insertCell().innerHTML = `<a href="#/file/${path}">${path}</a>`;
182
+ tr.insertCell().textContent = lines;
183
+ tr.insertCell().textContent = relevancy;
184
+ tr.insertCell().textContent = hit;
185
+ tr.insertCell().textContent = miss;
186
+ tr.insertCell().textContent = stale;
187
+ tr.insertCell().textContent = Math.round(coverage * 10000) / 100;
188
+ tr.insertCell().textContent = Math.round(staleness * 10000) / 100;
189
+ }
190
+ return el;
191
+ }
192
+
193
+ function renderFile(record) {
194
+ const el = cloneTemplate("tmpl-file");
195
+ const staleThresholdDays = getStaleThreshold(el);
196
+ el.querySelector("#title").textContent = record.path;
197
+ const digitWidth = Math.floor(Math.log10(Math.max(...record.execution_count))) + 1;
198
+ const highlighted = hljs.highlight(record.source, { language: "ruby" }).value;
199
+ el.querySelector("#code").innerHTML = highlighted
200
+ .split("\n")
201
+ .map((line, i) => {
202
+ const count = record.execution_count[i];
203
+ const displayCount = typeof count === "number" ? String(count).padStart(digitWidth) : "-".padStart(digitWidth);
204
+ const date = formatDate(record.last_executed_at[i]).padStart(10);
205
+ return `<span class="line ${colorClass(count, record.last_executed_at[i], staleThresholdDays)}"><span class="number">${displayCount}</span><time class="timestamp">${date}</time>${line}</span>`;
176
206
  })
177
- .reduce(
178
- (fragment, { path, lines, hit, miss, relevancy, coverage }) => {
179
- const tr = document.createElement("tr");
180
- tr.appendChild(
181
- (() => {
182
- const anchor = document.createElement("a");
183
- anchor.href = `#/file/${path}`;
184
- anchor.appendChild(document.createTextNode(path));
185
- const td = document.createElement("td");
186
- td.appendChild(anchor);
187
- return td;
188
- })()
189
- );
190
- tr.appendChild(createTdTextNode(lines));
191
- tr.appendChild(createTdTextNode(relevancy));
192
- tr.appendChild(createTdTextNode(hit));
193
- tr.appendChild(createTdTextNode(miss));
194
- tr.appendChild(
195
- createTdTextNode(Math.round(coverage * 10000) / 100)
196
- );
197
- fragment.appendChild(tr);
198
- return fragment;
199
- },
200
- document.createDocumentFragment()
201
- )
202
- );
207
+ .join("\n");
208
+ return el;
209
+ }
203
210
 
204
- document.addEventListener("DOMContentLoaded", () => {
205
- const routing = route(window.location.hash.slice(1));
206
- render(routing);
207
- });
211
+ function render() {
212
+ const routing = route();
213
+ const page = routing.page === "file" ? renderFile(routing.record) : renderIndex();
214
+ main.replaceChildren(page);
215
+ }
208
216
 
209
- window.addEventListener("popstate", (event) => {
210
- const routing = route(window.location.hash.slice(1));
211
- render(routing);
212
- });
217
+ render();
218
+ window.addEventListener("hashchange", render);
213
219
  </script>
214
220
  </body>
215
221
  </html>
@@ -11,12 +11,14 @@ module CoverRage
11
11
  @mutex = Mutex.new
12
12
  @path = path
13
13
  @db = SQLite3::Database.new(path)
14
+ @db.busy_handler { true }
14
15
  @db.execute <<-SQL
15
16
  create table if not exists records (
16
17
  path text primary key not null,
17
18
  revision blob not null,
18
19
  source text not null,
19
- execution_count text not null
20
+ execution_count text not null,
21
+ last_executed_at text not null
20
22
  )
21
23
  SQL
22
24
  process_ext = Module.new
@@ -25,6 +27,7 @@ module CoverRage
25
27
  store.instance_variable_get(:@db).close
26
28
  pid = super()
27
29
  store.instance_variable_set(:@db, SQLite3::Database.new(path))
30
+ store.instance_variable_get(:@db).busy_handler { true }
28
31
  pid
29
32
  end
30
33
  end
@@ -34,22 +37,21 @@ module CoverRage
34
37
  def transaction(&)
35
38
  @mutex.synchronize do
36
39
  @db.transaction(:exclusive, &)
37
- rescue SQLite3::BusyException
38
- retry
39
40
  end
40
41
  end
41
42
 
42
43
  def update(records)
43
44
  @db.execute(
44
- "insert or replace into records (path, revision, source, execution_count) values #{
45
- (['(?,?,?,?)'] * records.length).join(',')
45
+ "insert or replace into records (path, revision, source, execution_count, last_executed_at) values #{
46
+ (['(?,?,?,?,?)'] * records.length).join(',')
46
47
  }",
47
48
  records.each_with_object([]) do |record, memo|
48
49
  memo.push(
49
50
  record.path,
50
51
  record.revision,
51
52
  record.source,
52
- JSON.dump(record.execution_count)
53
+ JSON.dump(record.execution_count),
54
+ JSON.dump(record.last_executed_at)
53
55
  )
54
56
  end
55
57
  )
@@ -57,13 +59,14 @@ module CoverRage
57
59
 
58
60
  def list
59
61
  @db
60
- .execute('select path, revision, source, execution_count from records')
61
- .map do |(path, revision, source, execution_count)|
62
+ .execute('select path, revision, source, execution_count, last_executed_at from records')
63
+ .map do |(path, revision, source, execution_count, last_executed_at)|
62
64
  Record.new(
63
65
  path:,
64
66
  revision:,
65
67
  source:,
66
- execution_count: JSON.parse(execution_count)
68
+ execution_count: JSON.parse(execution_count),
69
+ last_executed_at: JSON.parse(last_executed_at)
67
70
  )
68
71
  end
69
72
  end
metadata CHANGED
@@ -1,70 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cover_rage
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Weihang Jian
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2024-12-30 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: minitest
13
+ name: pstore
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '5.18'
19
- type: :development
18
+ version: '0'
19
+ type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '5.18'
26
- - !ruby/object:Gem::Dependency
27
- name: rake
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '13.0'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '13.0'
40
- - !ruby/object:Gem::Dependency
41
- name: redis
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '5.3'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '5.3'
54
- - !ruby/object:Gem::Dependency
55
- name: sqlite3
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '2.5'
61
- type: :development
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '2.5'
25
+ version: '0'
68
26
  description: |
69
27
  cover_rage is a Ruby code coverage tool designed to be simple and easy to use. It can be used not only for test coverage but also in production services to identify unused code.
70
28
 
@@ -111,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
69
  - !ruby/object:Gem::Version
112
70
  version: '0'
113
71
  requirements: []
114
- rubygems_version: 3.6.2
72
+ rubygems_version: 4.0.4
115
73
  specification_version: 4
116
74
  summary: cover_rage is a Ruby code coverage tool designed to be simple and easy to
117
75
  use. It can be used not only for test coverage but also in production services to