testament-probe-ruby 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: 337fe8994c7ab64268d451d7b9f91e671251036eade4ed01905f42a10dd366f8
4
+ data.tar.gz: 5faf2fb1f40a1df3cbed28907b6871940ec05d498a284d955fa1b06fc2f78c18
5
+ SHA512:
6
+ metadata.gz: 55f1e5ff0529789880754d15acd90be7ee2a58d4104983800d5c592175f74de4a97597e3e49f1009dcc0c6f2114ada4ca4c1687ff00e1730ab841cdacdd6763c
7
+ data.tar.gz: 4c6d77433a6e09c18b446672af24937812a71c7a49c0cdd31213d2a89b544c5ca9634b576806d74027a0107db3328dc5b6b68d705442d35fc8b0102d2b3d05d9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # testament-probe-ruby
2
+
3
+ Small Ruby-side companion for collecting per-test line coverage and assertion
4
+ trace evidence consumed by testament.
5
+
6
+ ## Usage
7
+
8
+ Add the probe to the test process before tests run:
9
+
10
+ ```ruby
11
+ require "testament/probe"
12
+ ```
13
+
14
+ By default the probe writes `.testament/per-test-coverage.json` and
15
+ `.testament/trace.json`. Set `TESTAMENT_PROBE_OUTPUT` or
16
+ `TESTAMENT_TRACE_OUTPUT` to write another path.
17
+
18
+ The trace output records executed project lines, lines executed while assertion
19
+ methods are active, and recent executed lines observed when assertions begin.
20
+ `TESTAMENT_PROJECT_ROOT` controls the project-root filter, and
21
+ `TESTAMENT_TRACE_WINDOW` controls how many recent lines are attributed to an
22
+ assertion.
23
+
24
+ Line/call tracing adds noticeable overhead to the test run. Set
25
+ `TESTAMENT_TRACE=0` to collect per-test line coverage without trace evidence.
26
+
27
+ The probe installs hooks for RSpec and Minitest when those constants are loaded.
28
+ Load the probe after the test framework is required.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testament
4
+ module Probe
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,248 @@
1
+ require "coverage"
2
+ require "fileutils"
3
+ require "json"
4
+ require_relative "probe/version"
5
+
6
+ module Testament
7
+ module Probe
8
+ DEFAULT_OUTPUT = ".testament/per-test-coverage.json"
9
+ DEFAULT_TRACE_OUTPUT = ".testament/trace.json"
10
+ DEFAULT_TRACE_WINDOW = 200
11
+ ASSERTION_METHODS = %i[
12
+ expect should should_not to not_to to_not
13
+ assert assert_equal assert_same assert_nil assert_not_nil assert_empty
14
+ assert_includes assert_match assert_operator assert_predicate
15
+ assert_instance_of assert_kind_of assert_raises assert_throws
16
+ assert_output assert_silent
17
+ refute refute_equal refute_same refute_nil refute_empty
18
+ refute_includes refute_match refute_operator refute_predicate
19
+ refute_instance_of refute_kind_of
20
+ must_be must_equal must_include must_match must_raise must_be_nil
21
+ wont_be wont_equal wont_include wont_match wont_be_nil
22
+ ].freeze
23
+
24
+ class << self
25
+ def install!(
26
+ output: ENV.fetch("TESTAMENT_PROBE_OUTPUT", DEFAULT_OUTPUT),
27
+ trace_output: ENV.fetch("TESTAMENT_TRACE_OUTPUT", DEFAULT_TRACE_OUTPUT)
28
+ )
29
+ @output = output
30
+ @trace_output = trace_output
31
+ @root = File.expand_path(ENV.fetch("TESTAMENT_PROJECT_ROOT", Dir.pwd))
32
+ @probe_file = File.expand_path(__FILE__)
33
+ @trace_window = ENV.fetch("TESTAMENT_TRACE_WINDOW", DEFAULT_TRACE_WINDOW).to_i
34
+ @trace_enabled = ENV.fetch("TESTAMENT_TRACE", "1") != "0"
35
+ start_coverage
36
+ capture_coverage
37
+ install_rspec if defined?(RSpec)
38
+ install_minitest if defined?(Minitest::Test)
39
+ at_exit { write! }
40
+ end
41
+
42
+ def record(case_id)
43
+ start_coverage
44
+ start_trace
45
+ previous_case = @current_case
46
+ previous_recent_lines = @recent_lines
47
+ previous_assertion_depth = @assertion_depth
48
+ @recent_lines = []
49
+ @assertion_depth = 0
50
+ @current_case = case_id
51
+ trace_cases[case_id]
52
+ yield
53
+ ensure
54
+ merge_case(case_id, capture_coverage) if case_id
55
+ @recent_lines = previous_recent_lines || []
56
+ @assertion_depth = previous_assertion_depth || 0
57
+ @current_case = previous_case
58
+ end
59
+
60
+ def write!
61
+ output = @output || DEFAULT_OUTPUT
62
+ FileUtils.mkdir_p(File.dirname(output))
63
+ File.write(output, JSON.pretty_generate("cases" => cases))
64
+
65
+ trace_output = @trace_output || DEFAULT_TRACE_OUTPUT
66
+ FileUtils.mkdir_p(File.dirname(trace_output))
67
+ File.write(trace_output, JSON.pretty_generate("cases" => trace_cases))
68
+ end
69
+
70
+ private
71
+
72
+ def cases
73
+ @cases ||= {}
74
+ end
75
+
76
+ def trace_cases
77
+ @trace_cases ||= Hash.new do |cases, case_id|
78
+ cases[case_id] = { "executed" => {}, "checked" => {} }
79
+ end
80
+ end
81
+
82
+ def start_coverage
83
+ return false if coverage_running?
84
+
85
+ Coverage.start(lines: true)
86
+ true
87
+ end
88
+
89
+ def start_trace
90
+ return unless @trace_enabled
91
+
92
+ @tracepoint ||= TracePoint.new(:line, :call, :c_call, :return, :c_return) do |event|
93
+ case event.event
94
+ when :line
95
+ record_trace_line(event.path, event.lineno)
96
+ when :call, :c_call
97
+ enter_assertion if assertion_method?(event.method_id, event.defined_class)
98
+ when :return, :c_return
99
+ leave_assertion if assertion_method?(event.method_id, event.defined_class)
100
+ end
101
+ end
102
+ @tracepoint.enable unless @tracepoint.enabled?
103
+ end
104
+
105
+ def coverage_running?
106
+ Coverage.respond_to?(:running?) && Coverage.running?
107
+ end
108
+
109
+ def capture_coverage
110
+ Coverage.result(stop: false, clear: true)
111
+ rescue ArgumentError
112
+ Coverage.result
113
+ end
114
+
115
+ def merge_case(case_id, coverage)
116
+ normalized = normalize_coverage(coverage)
117
+ return if normalized.empty?
118
+
119
+ existing = cases.fetch(case_id, {})
120
+ normalized.each do |path, lines|
121
+ merged = (existing.fetch(path, []) + lines).uniq.sort
122
+ existing[path] = merged
123
+ end
124
+ cases[case_id] = existing
125
+ end
126
+
127
+ def record_trace_line(path, line)
128
+ return unless @current_case
129
+
130
+ path = normalize_trace_path(path)
131
+ return unless path
132
+
133
+ trace = trace_cases[@current_case]
134
+ append_trace_line(trace["executed"], path, line)
135
+ append_trace_line(trace["checked"], path, line) if assertion_active?
136
+ @recent_lines ||= []
137
+ @recent_lines << [path, line]
138
+ @recent_lines.shift while @recent_lines.length > trace_window
139
+ end
140
+
141
+ def enter_assertion
142
+ mark_recent_lines_checked
143
+ @assertion_depth = assertion_depth + 1
144
+ end
145
+
146
+ def leave_assertion
147
+ @assertion_depth = [assertion_depth - 1, 0].max
148
+ end
149
+
150
+ def assertion_active?
151
+ assertion_depth.positive?
152
+ end
153
+
154
+ def assertion_depth
155
+ @assertion_depth ||= 0
156
+ end
157
+
158
+ def mark_recent_lines_checked
159
+ return unless @current_case
160
+
161
+ checked = trace_cases[@current_case]["checked"]
162
+ @recent_lines.each do |path, line|
163
+ append_trace_line(checked, path, line)
164
+ end
165
+ end
166
+
167
+ def append_trace_line(files, path, line)
168
+ lines = files.fetch(path, [])
169
+ lines << line
170
+ files[path] = lines.uniq.sort
171
+ end
172
+
173
+ def normalize_trace_path(path)
174
+ return if path.nil? || path.empty?
175
+ return if path.start_with?("<")
176
+
177
+ expanded = File.expand_path(path)
178
+ root = @root || Dir.pwd
179
+ return if expanded == @probe_file
180
+ return unless expanded == root || expanded.start_with?("#{root}/")
181
+
182
+ expanded.delete_prefix("#{root}/")
183
+ end
184
+
185
+ def assertion_method?(method_id, defined_class)
186
+ return false unless ASSERTION_METHODS.include?(method_id)
187
+
188
+ owner = defined_class.to_s
189
+ owner.include?("RSpec") || owner.include?("Minitest") || owner.include?("Test::Unit")
190
+ end
191
+
192
+ def trace_window
193
+ [@trace_window || DEFAULT_TRACE_WINDOW, 1].max
194
+ end
195
+
196
+ def normalize_coverage(coverage)
197
+ coverage.each_with_object({}) do |(path, value), result|
198
+ lines = line_hits(value)
199
+ covered = lines.each_with_index.filter_map do |hits, index|
200
+ index + 1 if hits && hits.positive?
201
+ end
202
+ result[path] = covered unless covered.empty?
203
+ end
204
+ end
205
+
206
+ def line_hits(value)
207
+ if value.is_a?(Hash)
208
+ unless value.key?(:lines) || value.key?("lines")
209
+ warn_missing_line_coverage
210
+ return []
211
+ end
212
+ value.fetch(:lines, value.fetch("lines", []))
213
+ else
214
+ value || []
215
+ end
216
+ end
217
+
218
+ def warn_missing_line_coverage
219
+ return if @warned_missing_line_coverage
220
+
221
+ warn "testament probe: Coverage is active without lines mode; per-test coverage will be empty"
222
+ @warned_missing_line_coverage = true
223
+ end
224
+
225
+ def install_rspec
226
+ RSpec.configure do |config|
227
+ config.around(:each) do |example|
228
+ Testament::Probe.record(example.full_description) { example.run }
229
+ end
230
+ end
231
+ end
232
+
233
+ def install_minitest
234
+ return if Minitest::Test < MinitestIntegration
235
+
236
+ Minitest::Test.prepend(MinitestIntegration)
237
+ end
238
+ end
239
+
240
+ module MinitestIntegration
241
+ def run
242
+ Testament::Probe.record("#{self.class}##{name}") { super }
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ Testament::Probe.install!
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: testament-probe-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Collects per-test line coverage and assertion trace evidence for the
13
+ testament test-quality analyzer.
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files:
17
+ - LICENSE.txt
18
+ - README.md
19
+ files:
20
+ - LICENSE.txt
21
+ - README.md
22
+ - lib/testament/probe.rb
23
+ - lib/testament/probe/version.rb
24
+ homepage: https://github.com/ydah/testament
25
+ licenses:
26
+ - MIT
27
+ metadata:
28
+ source_code_uri: https://github.com/ydah/testament
29
+ homepage_uri: https://github.com/ydah/testament
30
+ bug_tracker_uri: https://github.com/ydah/testament/issues
31
+ allowed_push_host: https://rubygems.org
32
+ rubygems_mfa_required: 'true'
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.1'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 4.0.6
48
+ specification_version: 4
49
+ summary: Per-test Ruby coverage and trace probe for testament
50
+ test_files: []