minitest-heat 1.2.0 → 2.0.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.
data/RELEASING.md ADDED
@@ -0,0 +1,190 @@
1
+ # Releasing Minitest Heat
2
+
3
+ ## Quick Reference
4
+
5
+ ```bash
6
+ # 1. Update version and changelog
7
+ # 2. Commit and push
8
+ git add -A && git commit -m "Release vX.Y.Z" && git push
9
+
10
+ # 3. Tag and push
11
+ git tag vX.Y.Z && git push origin vX.Y.Z
12
+
13
+ # Done - automation handles the rest
14
+ ```
15
+
16
+ ## How It Works
17
+
18
+ Releases are fully automated via GitHub Actions:
19
+
20
+ 1. **Branch protection** requires CI to pass before merging to `main`
21
+ 2. When you push a version tag, the release workflow:
22
+ - Validates the tag points to a commit on `main` (ensures CI passed)
23
+ - Builds and publishes the gem to RubyGems
24
+ - Creates a GitHub Release with changelog excerpt
25
+
26
+ No redundant test runs. If it's on `main`, it already passed CI.
27
+
28
+ ## Release Steps
29
+
30
+ ### 1. Update Version
31
+
32
+ Edit `lib/minitest/heat/version.rb`:
33
+
34
+ ```ruby
35
+ VERSION = 'X.Y.Z'
36
+ ```
37
+
38
+ ### 2. Update CHANGELOG
39
+
40
+ Move items from `[Unreleased]` to a new version section:
41
+
42
+ ```markdown
43
+ ## [Unreleased]
44
+
45
+ ## [X.Y.Z] - YYYY-MM-DD
46
+
47
+ ### Added
48
+ - New feature description
49
+
50
+ ### Fixed
51
+ - Bug fix description
52
+ ```
53
+
54
+ ### 3. Commit, Tag, Push
55
+
56
+ ```bash
57
+ git add lib/minitest/heat/version.rb CHANGELOG.md
58
+ git commit -m "Release vX.Y.Z"
59
+ git push origin main
60
+ git tag vX.Y.Z
61
+ git push origin vX.Y.Z
62
+ ```
63
+
64
+ ### 4. Verify
65
+
66
+ - Watch the [Actions tab](https://github.com/garrettdimon/minitest-heat/actions) for workflow completion
67
+ - Check [RubyGems](https://rubygems.org/gems/minitest-heat) for the new version
68
+ - Check [GitHub Releases](https://github.com/garrettdimon/minitest-heat/releases) for the release page
69
+
70
+ ## Versioning Policy
71
+
72
+ Follow [Semantic Versioning](https://semver.org/):
73
+
74
+ - **MAJOR** (x.0.0): Breaking changes to public API or configuration
75
+ - **MINOR** (0.x.0): New features, deprecations
76
+ - **PATCH** (0.0.x): Bug fixes, documentation
77
+
78
+ ### What's a Breaking Change?
79
+
80
+ - Removing or renaming public classes/methods
81
+ - Changing method signatures incompatibly
82
+ - Changing default configuration behavior
83
+ - Dropping Ruby version support
84
+
85
+ ## Local Tools
86
+
87
+ ### Full Preflight Check
88
+
89
+ Run all checks before pushing:
90
+
91
+ ```bash
92
+ bundle exec rake release:preflight
93
+ ```
94
+
95
+ This runs tests, security audit, and release validation in sequence.
96
+
97
+ ### Individual Tasks
98
+
99
+ | Task | Purpose |
100
+ |------|---------|
101
+ | `rake release:preflight` | Run all checks (test, audit, check) |
102
+ | `rake release:check` | Validate version format, changelog entry, git state |
103
+ | `rake release:audit` | Check for vulnerable dependencies |
104
+ | `rake release:dry_run` | Build gem locally, show contents and size |
105
+ | `rake test` | Run test suite |
106
+
107
+ ### Preview a Release
108
+
109
+ Before tagging, preview what the gem will contain:
110
+
111
+ ```bash
112
+ bundle exec rake release:dry_run
113
+ ```
114
+
115
+ This builds the gem, displays its contents and size, then cleans up.
116
+
117
+ ## One-Time Setup
118
+
119
+ ### RubyGems Trusted Publishing
120
+
121
+ 1. Go to [rubygems.org/profile/oidc/pending_trusted_publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers)
122
+ 2. Add trusted publisher:
123
+ - **Gem name:** `minitest-heat`
124
+ - **Repository owner:** `garrettdimon`
125
+ - **Repository name:** `minitest-heat`
126
+ - **Workflow filename:** `release.yml`
127
+ - **Environment:** `rubygems`
128
+
129
+ ### GitHub Environment
130
+
131
+ 1. Go to repository Settings > Environments
132
+ 2. Create environment named `rubygems`
133
+ 3. (Optional) Add required reviewers for extra safety
134
+
135
+ ### Repository Ruleset
136
+
137
+ 1. Go to repository Settings > Rules > Rulesets
138
+ 2. Edit the `main` ruleset (or create one targeting the default branch)
139
+ 3. Enable "Require status checks to pass" with these checks:
140
+ - `Security`
141
+ - `Test (Ruby 3.2)`
142
+ - `Test (Ruby 3.3)`
143
+ - `Test (Ruby 3.4)`
144
+ - `Test (Ruby 4.0)`
145
+ - `Changelog`
146
+ - `Version`
147
+
148
+ ## Troubleshooting
149
+
150
+ ### Release workflow fails with "must point to commit on main"
151
+
152
+ You tagged a commit that isn't on the main branch. Delete the tag and re-tag a commit on main:
153
+
154
+ ```bash
155
+ git tag -d vX.Y.Z # Delete local tag
156
+ git push origin :vX.Y.Z # Delete remote tag
157
+ git checkout main
158
+ git pull
159
+ git tag vX.Y.Z
160
+ git push origin vX.Y.Z
161
+ ```
162
+
163
+ ### "You do not have permission to push to this gem"
164
+
165
+ The RubyGems trusted publisher isn't configured, or the environment name doesn't match. Check the one-time setup steps above.
166
+
167
+ ### Forgot to update CHANGELOG
168
+
169
+ Delete the tag, update CHANGELOG, amend the commit, re-tag:
170
+
171
+ ```bash
172
+ git tag -d vX.Y.Z
173
+ git push origin :vX.Y.Z
174
+ # Update CHANGELOG.md
175
+ git add CHANGELOG.md
176
+ git commit --amend --no-edit
177
+ git push --force-with-lease origin main
178
+ git tag vX.Y.Z
179
+ git push origin vX.Y.Z
180
+ ```
181
+
182
+ ## Manual Release (Fallback)
183
+
184
+ If automation fails and you need to publish manually:
185
+
186
+ ```bash
187
+ bundle exec rake release
188
+ ```
189
+
190
+ This builds the gem and pushes to RubyGems using your local credentials.
data/Rakefile CHANGED
@@ -10,3 +10,120 @@ Rake::TestTask.new(:test) do |t|
10
10
  end
11
11
 
12
12
  task default: :test
13
+
14
+ # rubocop:disable Metrics/BlockLength
15
+ namespace :release do
16
+ desc 'Run bundle-audit to check for vulnerable dependencies'
17
+ task :audit do
18
+ puts 'Running security audit...'
19
+ sh 'bundle exec bundle-audit check --update'
20
+ end
21
+
22
+ desc 'Validate version, changelog, and git state before release'
23
+ task :check do
24
+ require_relative 'lib/minitest/heat/version'
25
+ errors = ReleaseChecker.new(Minitest::Heat::VERSION).validate
26
+ if errors.any?
27
+ puts "\nRelease check failed:"
28
+ errors.each { |e| puts " - #{e}" }
29
+ exit 1
30
+ else
31
+ puts 'All release checks passed.'
32
+ end
33
+ end
34
+
35
+ desc 'Run all pre-release checks (tests, audit, release:check)'
36
+ task preflight: %i[test audit check] do
37
+ puts "\nAll preflight checks passed. Ready to release."
38
+ end
39
+
40
+ desc 'Build gem locally and show contents (dry run)'
41
+ task :dry_run do
42
+ require_relative 'lib/minitest/heat/version'
43
+ DryRun.new(Minitest::Heat::VERSION).run
44
+ end
45
+ end
46
+ # rubocop:enable Metrics/BlockLength
47
+
48
+ # Validates release readiness
49
+ class ReleaseChecker
50
+ def initialize(version)
51
+ @version = version
52
+ @errors = []
53
+ end
54
+
55
+ def validate
56
+ puts "Checking release readiness for v#{@version}..."
57
+ check_version_format
58
+ check_changelog
59
+ check_git_clean
60
+ check_main_branch
61
+ @errors
62
+ end
63
+
64
+ private
65
+
66
+ def check_version_format
67
+ return if @version.match?(/\A\d+\.\d+\.\d+\z/)
68
+
69
+ @errors << "Version '#{@version}' is not valid semver (expected X.Y.Z)"
70
+ end
71
+
72
+ def check_changelog
73
+ changelog = File.read('CHANGELOG.md')
74
+ return if changelog.include?("[#{@version}]")
75
+
76
+ @errors << "CHANGELOG.md has no entry for version #{@version}"
77
+ end
78
+
79
+ def check_git_clean
80
+ return if `git status --porcelain`.empty?
81
+
82
+ @errors << 'Working directory has uncommitted changes'
83
+ end
84
+
85
+ def check_main_branch
86
+ current_branch = `git branch --show-current`.strip
87
+ return if current_branch == 'main'
88
+
89
+ @errors << "Not on main branch (currently on '#{current_branch}')"
90
+ end
91
+ end
92
+
93
+ # Builds gem and displays contents without publishing
94
+ class DryRun
95
+ def initialize(version)
96
+ @version = version
97
+ @gem_file = "minitest-heat-#{version}.gem"
98
+ end
99
+
100
+ def run
101
+ build || abort('Gem build failed')
102
+ show_contents
103
+ show_size
104
+ ensure
105
+ cleanup if File.exist?(@gem_file)
106
+ end
107
+
108
+ private
109
+
110
+ def build
111
+ puts "Building #{@gem_file}..."
112
+ system 'gem build minitest-heat.gemspec --silent'
113
+ end
114
+
115
+ def show_contents
116
+ puts "\nGem contents:"
117
+ system "tar -tf #{@gem_file}"
118
+ end
119
+
120
+ def show_size
121
+ size = File.size(@gem_file)
122
+ puts "\nGem size: #{(size / 1024.0).round(1)} KB"
123
+ end
124
+
125
+ def cleanup
126
+ File.delete(@gem_file)
127
+ puts "Cleaned up #{@gem_file}"
128
+ end
129
+ end
@@ -19,15 +19,18 @@ module Minitest
19
19
  end
20
20
 
21
21
  def max_location
22
- locations.size - 1
22
+ [locations.size - 1, 0].max
23
23
  end
24
24
 
25
25
  def limit
26
+ return 0 if locations.empty?
27
+
28
+ # Find the minimum relevant index, then add 1 to convert from index to count
26
29
  [
27
- DEFAULT_LINE_COUNT,
30
+ DEFAULT_LINE_COUNT - 1,
28
31
  earliest_project_location,
29
32
  max_location
30
- ].compact.min
33
+ ].compact.min + 1
31
34
  end
32
35
  end
33
36
  end
@@ -10,7 +10,9 @@ module Minitest
10
10
  module LineParser
11
11
  # Parses a line from a backtrace in order to convert it to usable components
12
12
  def self.read(raw_text)
13
- raw_pathname, raw_line_number, raw_container = raw_text.split(':')
13
+ return nil if raw_text.nil? || raw_text.empty?
14
+
15
+ raw_pathname, raw_line_number, raw_container = raw_text.to_s.split(':')
14
16
  raw_container = raw_container&.delete_prefix('in `')&.delete_suffix("'")
15
17
 
16
18
  ::Minitest::Heat::Location.new(
@@ -31,7 +31,7 @@ module Minitest
31
31
  def locations
32
32
  return [] if raw_backtrace.nil?
33
33
 
34
- @locations ||= raw_backtrace.map { |entry| Backtrace::LineParser.read(entry) }
34
+ @locations ||= raw_backtrace.map { |entry| Backtrace::LineParser.read(entry) }.compact
35
35
  end
36
36
 
37
37
  # All entries from the backtrace within the project and sorted with the most recently modified
@@ -63,33 +63,49 @@ module Minitest
63
63
  #
64
64
  # @return [Integer] the problem weight for the file
65
65
  def weight
66
- weight = 0
67
- issues.each_pair do |type, values|
68
- weight += values.size * WEIGHTS.fetch(type, 0)
69
- end
70
- weight
66
+ issues.sum { |type, values| values.size * WEIGHTS.fetch(type, 0) }
71
67
  end
72
68
 
73
69
  # The total issue count for the file across all issue types. Includes duplicates if they exist
74
70
  #
75
71
  # @return [Integer] the sum of the counts for all line numbers for all issue types
76
72
  def count
77
- count = 0
78
- issues.each_pair do |_type, values|
79
- count += values.size
80
- end
81
- count
73
+ issues.sum { |_type, values| values.size }
82
74
  end
83
75
 
84
76
  # The full set of unique line numbers across all issue types
85
77
  #
86
78
  # @return [Array<Integer>] the full set of unique offending line numbers for the hit
87
79
  def line_numbers
88
- line_numbers = []
89
- issues.each_pair do |_type, values|
90
- line_numbers += values
80
+ issues.values.flatten.uniq.sort
81
+ end
82
+
83
+ # Generates a hash representation for JSON serialization
84
+ #
85
+ # @return [Hash] hit data with file, weight, and line details
86
+ def to_h
87
+ {
88
+ file: relative_path,
89
+ weight: weight,
90
+ lines: lines_summary
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ def relative_path
97
+ pathname.to_s.delete_prefix("#{Dir.pwd}/")
98
+ end
99
+
100
+ def lines_summary
101
+ line_numbers.map do |line_num|
102
+ traces = lines[line_num.to_s] || []
103
+ {
104
+ line: line_num,
105
+ types: traces.map(&:type).uniq,
106
+ count: traces.size
107
+ }
91
108
  end
92
- line_numbers.uniq.sort
93
109
  end
94
110
  end
95
111
  end
@@ -200,7 +200,9 @@ module Minitest
200
200
  #
201
201
  # @return [String] the first line of the exception message
202
202
  def first_line_of_exception_message
203
- text = message.split("\n")[0]
203
+ return '' if message.nil? || message.empty?
204
+
205
+ text = message.split("\n")[0].to_s
204
206
 
205
207
  text.size > exception_message_limit ? "#{text[0..exception_message_limit]}..." : text
206
208
  end
@@ -208,6 +210,32 @@ module Minitest
208
210
  def exception_message_limit
209
211
  200
210
212
  end
213
+
214
+ # Generates a hash representation for JSON serialization
215
+ #
216
+ # @return [Hash] issue data
217
+ def to_h
218
+ {
219
+ type: type,
220
+ test_class: test_class,
221
+ test_name: test_identifier,
222
+ execution_time: execution_time,
223
+ assertions: assertions,
224
+ message: message,
225
+ test_location: locations.test_definition&.to_h,
226
+ failure_location: failure_location_hash
227
+ }
228
+ end
229
+
230
+ private
231
+
232
+ def failure_location_hash
233
+ location = locations.most_relevant
234
+ return nil if location.nil?
235
+ return nil if location == locations.test_definition
236
+
237
+ location.to_h
238
+ end
211
239
  end
212
240
  end
213
241
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+
3
5
  module Minitest
4
6
  module Heat
5
7
  # Consistent structure for extracting information about a given location. In addition to the
@@ -42,6 +44,17 @@ module Minitest
42
44
  ]
43
45
  end
44
46
 
47
+ # Generates a hash representation for JSON serialization
48
+ #
49
+ # @return [Hash] location data with file, line, and container
50
+ def to_h
51
+ {
52
+ file: relative_filename,
53
+ line: line_number,
54
+ container: container
55
+ }
56
+ end
57
+
45
58
  # A short relative pathname and line number pair
46
59
  #
47
60
  # @return [String] the short filename/line number combo. ex. `dir/file.rb:23`
@@ -30,6 +30,13 @@ module Minitest
30
30
  hot_files.take(MAXIMUM_FILES_TO_SHOW)
31
31
  end
32
32
 
33
+ # Generates a hash representation for JSON serialization
34
+ #
35
+ # @return [Array<Hash>] array of hit hashes sorted by weight (highest first)
36
+ def to_h
37
+ hot_files.map(&:to_h)
38
+ end
39
+
33
40
  private
34
41
 
35
42
  # Sorts the files by hit "weight" so that the most problematic files are at the beginning
@@ -121,7 +121,8 @@ module Minitest
121
121
  def source_code_line_token(location)
122
122
  return nil unless location.project_file?
123
123
 
124
- [:muted, " #{Output::SYMBOLS[:arrow]} `#{location.source_code.line.strip}`"]
124
+ source_line = location.source_code.line
125
+ [:muted, " #{Output::SYMBOLS[:arrow]} `#{source_line&.strip || '(source unavailable)'}`"]
125
126
  end
126
127
 
127
128
  def containining_element_token(location)
@@ -69,7 +69,9 @@ module Minitest
69
69
  # @return [String] the cleaned up version of the test name
70
70
  def test_name(issue)
71
71
  test_prefix = 'test_'
72
- identifier = issue.test_identifier
72
+ identifier = issue.test_identifier.to_s
73
+
74
+ return 'Unknown test' if identifier.empty?
73
75
 
74
76
  if identifier.start_with?(test_prefix)
75
77
  identifier.delete_prefix(test_prefix).gsub('_', ' ').capitalize
@@ -87,22 +89,24 @@ module Minitest
87
89
  end
88
90
 
89
91
  def test_location_tokens
92
+ source_line = locations.test_failure.source_code.line
90
93
  [
91
94
  [:default, locations.test_definition.relative_filename],
92
95
  [:muted, ':'],
93
96
  [:default, locations.test_definition.line_number],
94
97
  arrow_token,
95
98
  [:default, locations.test_failure.line_number],
96
- [:muted, "\n #{locations.test_failure.source_code.line.strip}"]
99
+ [:muted, "\n #{source_line&.strip || '(source unavailable)'}"]
97
100
  ]
98
101
  end
99
102
 
100
103
  def location_tokens
104
+ source_line = locations.most_relevant.source_code.line
101
105
  [
102
106
  [:default, locations.most_relevant.relative_filename],
103
107
  [:muted, ':'],
104
108
  [:default, locations.most_relevant.line_number],
105
- [:muted, "\n #{locations.most_relevant.source_code.line.strip}"]
109
+ [:muted, "\n #{source_line&.strip || '(source unavailable)'}"]
106
110
  ]
107
111
  end
108
112
 
@@ -110,12 +114,15 @@ module Minitest
110
114
  filename = locations.project.filename
111
115
  line_number = locations.project.line_number
112
116
  source = Minitest::Heat::Source.new(filename, line_number: line_number)
117
+ source_line = source.line
113
118
 
114
- [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source.line.strip}`"]]
119
+ [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source_line&.strip || '(source unavailable)'}`"]]
115
120
  end
116
121
 
117
122
  def summary_tokens
118
- [[:italicized, issue.summary.delete_suffix('---------------').strip]]
123
+ summary_text = issue.summary.to_s
124
+ cleaned = summary_text.delete_suffix('---------------').strip
125
+ [[:italicized, cleaned.empty? ? '(no details available)' : cleaned]]
119
126
  end
120
127
 
121
128
  def slowness_summary_tokens
@@ -9,7 +9,12 @@ module Minitest
9
9
 
10
10
  attr_accessor :results, :timer
11
11
 
12
- def_delegators :@results, :issues, :errors, :brokens, :failures, :skips, :painfuls, :slows, :problems?
12
+ # Explicitly define issues to avoid Forwardable warning about Object#issues private method
13
+ def issues
14
+ @results.issues
15
+ end
16
+
17
+ def_delegators :@results, :errors, :brokens, :failures, :skips, :painfuls, :slows, :problems?
13
18
 
14
19
  def initialize(results, timer)
15
20
  @results = results
@@ -17,10 +17,10 @@ module Minitest
17
17
  failure: %i[default red],
18
18
  skipped: %i[default yellow],
19
19
  warning_light: %i[light yellow],
20
- italicized: %i[italic gray],
20
+ italicized: %i[italic default],
21
21
  bold: %i[bold default],
22
22
  default: %i[default default],
23
- muted: %i[light gray]
23
+ muted: %i[light default]
24
24
  }.freeze
25
25
 
26
26
  attr_accessor :style_key, :content
@@ -154,8 +154,9 @@ module Minitest
154
154
  tokens.each do |token|
155
155
  begin
156
156
  print Token.new(*token).to_s(token_format)
157
- rescue
158
- puts token.inspect
157
+ rescue ArgumentError => e
158
+ # Token format error - output debug info and continue
159
+ puts "Token error (#{e.message}): #{token.inspect}"
159
160
  end
160
161
  end
161
162
  newline
@@ -35,15 +35,15 @@ module Minitest
35
35
  # numbers if the tests reference a shared method with an assertion in it. So in those cases,
36
36
  # the backtrace is simply the test definition
37
37
  backtrace = if issue.error?
38
- # With errors, we have a backtrace
39
- issue.locations.backtrace.project_locations
40
- else
41
- # With failures, the test definition is the most granular backtrace equivalent
42
- location = issue.locations.test_definition
43
- location.raw_container = issue.test_identifier
38
+ # With errors, we have a backtrace
39
+ issue.locations.backtrace.project_locations
40
+ else
41
+ # With failures, the test definition is the most granular backtrace equivalent
42
+ location = issue.locations.test_definition
43
+ location.raw_container = issue.test_identifier
44
44
 
45
- [location]
46
- end
45
+ [location]
46
+ end
47
47
 
48
48
  @heat_map.add(pathname, line_number, issue.type, backtrace: backtrace)
49
49
  end
@@ -76,6 +76,39 @@ module Minitest
76
76
  @slows ||= select_issues(:slow).sort_by(&:execution_time).reverse
77
77
  end
78
78
 
79
+ # Returns count statistics by issue type
80
+ #
81
+ # @return [Hash] counts for each issue type
82
+ def statistics
83
+ {
84
+ total: issues.size,
85
+ errors: errors.size,
86
+ broken: brokens.size,
87
+ failures: failures.size,
88
+ skipped: skips.size,
89
+ painful: painfuls.size,
90
+ slow: slows.size
91
+ }
92
+ end
93
+
94
+ # Returns all issues that are not successes
95
+ #
96
+ # @return [Array<Issue>] issues that represent problems (errors, failures, skips, slow)
97
+ def issues_with_problems
98
+ issues.select(&:hit?)
99
+ end
100
+
101
+ # Generates a hash representation for JSON serialization
102
+ #
103
+ # @return [Hash] results data
104
+ def to_h
105
+ {
106
+ statistics: statistics,
107
+ heat_map: heat_map.to_h,
108
+ issues: issues_with_problems.map(&:to_h)
109
+ }
110
+ end
111
+
79
112
  private
80
113
 
81
114
  def select_issues(issue_type)