simplecov-sorbet 0.1.0 → 0.2.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: 769829fc88734ccfb203a5472a779e267ba07dac55d6fd6099955fd9de090740
4
- data.tar.gz: a948a59b02a9e94812f85ebdce285629d4b665617209df09b20641069498ac94
3
+ metadata.gz: 73744e2677661e69504801208d5e5e99224532de82e6ba71c62f8cd10df75aed
4
+ data.tar.gz: ca8f0945384722715fb35b473f64d80083b4a8d00e4240c4e5796f7bedc33384
5
5
  SHA512:
6
- metadata.gz: 577b36bd437cacde42b28a22b17d480b7983cfd6ab88c93ed14a955dc79656e650d8cf233a539c173e87a1277fa9681b4855a0128755c4464b2c58e0bfe36c0c
7
- data.tar.gz: fea6c1ed8d99d3d66111bcaad5369e77867321025b16730b0b12e4e6c2d374f8c288db3eb8a0264fe019622e352c981f99db3361164b02a14266234cd4e5f62f
6
+ metadata.gz: b6695fce11be79e42e782781912385748a65a374427e5484ba8c7250ad97589d10a3a18a89c494bd9858cb15cc9b5f569702c4e02af0266001a338d9420f6d06
7
+ data.tar.gz: 1e2a853a3fb0da0a5a693a5f8ae88d76705e12d498504fbbd2a83465fa2c2f69df535fcdf03135d549be25d7603011e5bccdcb83d28b5b80c2507764f0faded4
data/.rubocop.yml CHANGED
@@ -1,11 +1,19 @@
1
1
  inherit_gem:
2
2
  rubocop-shopify: rubocop.yml
3
3
 
4
+ inherit_mode:
5
+ merge:
6
+ - Exclude
7
+
4
8
  plugins:
5
9
  - rubocop-sorbet
6
10
 
7
11
  AllCops:
8
12
  SuggestExtensions: false
13
+ Exclude:
14
+ # Polyglot sh/ruby bootstrap scripts (#!/bin/sh preamble re-execing via
15
+ # `ruby -x`); not parseable as plain Ruby.
16
+ - bin/**/*
9
17
 
10
18
  # Tests are written in the RSpock dialect: bare comparisons in Then/Expect
11
19
  # blocks and block-name constants (Given/When/Then/Where/Cleanup) are
data/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] - 2026-07-29
8
+ ### Added
9
+ - `sig` block skipping: all multi-line `sig` blocks (bare or with a receiver, e.g. `T::Sig::WithoutRuntime.sig`) are excluded from coverage. Sigs are type metadata whose correctness `srb tc` owns; an untested method still reports its body as the miss.
10
+ - `T.absurd` send skipping: exhaustiveness checks are unreachable by definition when the code is correct, so their lines no longer read as permanent misses.
11
+
12
+ ### Changed
13
+ - `TypeAliasRanges` is now `IgnoredRanges`, one analysis pass collecting all three construct kinds.
14
+
7
15
  ## [0.1.0] - 2026-07-29
8
16
  ### Initial release
9
17
  - `T.type_alias` block skipping: requiring `simplecov/sorbet` prepends an extension onto `SimpleCov::Directive.disabled_ranges` that parses each covered file (Prism via ast_transform's analysis API) and appends every alias block's line range to all three directive categories. Multi-line alias bodies are runtime-unreachable by design and no longer read as coverage misses.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- simplecov-sorbet (0.1.0)
4
+ simplecov-sorbet (0.2.0)
5
5
  ast_transform (~> 3.1)
6
6
  simplecov (~> 1.0)
7
7
  sorbet-runtime
@@ -179,7 +179,7 @@ CHECKSUMS
179
179
  rubydex (0.3.0-x86_64-darwin) sha256=59d20c98b0bf2226f5db5bee14cf57075aa93aa0c3fa0176f496a217691ff7b6
180
180
  rubydex (0.3.0-x86_64-linux) sha256=dfe4026591f226b4a1b53f82f86dc14fad2e7c221ee8a5be99a0a46bf2d58b04
181
181
  simplecov (1.0.3) sha256=38ef0514f16ae7562f0d0f4df02610071115103d301b6de7dacbcc000082e39b
182
- simplecov-sorbet (0.1.0)
182
+ simplecov-sorbet (0.2.0)
183
183
  sorbet (0.6.13367) sha256=099d9f5f420e09606deb84c9110f352e1caa970eabc01978c55dc6eb089858ce
184
184
  sorbet-runtime (0.6.13367) sha256=4891cafce050f32f0e03f593a63cfa2ed535e541633b75a035ed36fb1b067d42
185
185
  sorbet-static (0.6.13367-aarch64-linux) sha256=46416a8ed47e5c12d129a6f6418a98d2bb56d8ebeaea6d6a6a47306b4307acc5
data/README.md CHANGED
@@ -1,8 +1,13 @@
1
+ [![CI](https://github.com/d3mlabs/simplecov-sorbet/actions/workflows/ci.yml/badge.svg)](https://github.com/d3mlabs/simplecov-sorbet/actions/workflows/ci.yml)
2
+ [![codecov](https://codecov.io/gh/d3mlabs/simplecov-sorbet/branch/main/graph/badge.svg)](https://codecov.io/gh/d3mlabs/simplecov-sorbet)
3
+
1
4
  # SimpleCov::Sorbet
2
5
 
3
- A [SimpleCov](https://github.com/simplecov-ruby/simplecov) extension for [Sorbet](https://sorbet.org) codebases: it skips constructs Sorbet makes runtime-unreachable by design, so they stop reading as coverage misses.
6
+ A [SimpleCov](https://github.com/simplecov-ruby/simplecov) extension for [Sorbet](https://sorbet.org) codebases: it skips type-level Sorbet constructs that coverage should not measure, so they stop reading as coverage misses.
7
+
8
+ Three constructs are skipped:
4
9
 
5
- Today that means multi-line `T.type_alias` blocks. sorbet-runtime resolves aliases lazily and collection checks are shallow, so the block body of
10
+ - **Multi-line `T.type_alias` blocks.** sorbet-runtime resolves aliases lazily and collection checks are shallow, so the block body of
6
11
 
7
12
  ```ruby
8
13
  ResolvedSegment = T.type_alias do
@@ -14,7 +19,13 @@ ResolvedSegment = T.type_alias do
14
19
  end
15
20
  ```
16
21
 
17
- never executes — and SimpleCov (and any patch-coverage gate built on it, like Codecov's) reports those lines as uncovered. The usual workarounds are cramming the alias onto one line or sprinkling `# simplecov:disable` comments; both encode a tool-compatibility fact into every file that has an alias. This gem moves that knowledge to the layer that owns it: detection is purely syntactic (a [Prism](https://github.com/ruby/prism)-backed AST pass via [ast_transform](https://github.com/rspockframework/ast-transform)), and the found ranges feed straight into SimpleCov's skip machinery.
22
+ never executes — and SimpleCov (and any patch-coverage gate built on it, like Codecov's) reports those lines as uncovered.
23
+
24
+ - **`sig` blocks** (bare or with a receiver such as `T::Sig::WithoutRuntime.sig`). Sigs are type metadata whose correctness `srb tc` owns; coverage should measure behavior. Skipping them sharpens the signal: an untested method reports its *body* as the miss, without sig-line noise — and `T::Sig::WithoutRuntime` sigs, which never evaluate at runtime, stop being permanent false positives. Detection matches any `sig` call with a multi-line block; a non-Sorbet DSL also named `sig` would be over-matched, an accepted risk in a Sorbet codebase.
25
+
26
+ - **`T.absurd` sends.** Exhaustiveness checks are unreachable by definition when the code is correct — Sorbet statically proves the `else` branch can't be taken — so in healthy code the line is a permanent miss.
27
+
28
+ The usual workarounds are cramming constructs onto one line or sprinkling `# simplecov:disable` comments; both encode a tool-compatibility fact into every file. This gem moves that knowledge to the layer that owns it: detection is purely syntactic (a [Prism](https://github.com/ruby/prism)-backed AST pass via [ast_transform](https://github.com/rspockframework/ast-transform)), and the found ranges feed straight into SimpleCov's skip machinery.
18
29
 
19
30
  ## Installation
20
31
 
@@ -35,7 +46,13 @@ SimpleCov.start
35
46
 
36
47
  ## How it works
37
48
 
38
- Requiring `simplecov/sorbet` prepends an extension onto `SimpleCov::Directive.disabled_ranges`, the seam SimpleCov 1.0 consults for `# simplecov:disable` ranges — for both loaded files (`SourceFile`) and tracked-but-unloaded files (`LinesClassifier`). The extension parses each covered file, collects the line range of every `T.type_alias` block (including the `::T` form), and appends those ranges to all three directive categories (`line`, `branch`, `method` — the block body is runtime-unreachable, so anything inside it is skippable). Files the parser rejects contribute no ranges and are otherwise reported untouched.
49
+ Requiring `simplecov/sorbet` prepends an extension onto `SimpleCov::Directive.disabled_ranges`, the seam SimpleCov 1.0 consults for `# simplecov:disable` ranges — for both loaded files (`SourceFile`) and tracked-but-unloaded files (`LinesClassifier`). The extension parses each covered file, collects the line range of every ignored construct (`T.type_alias` blocks including the `::T` form, multi-line `sig` blocks, and `T.absurd` sends), and appends those ranges to all three directive categories (`line`, `branch`, `method` — type-level constructs are not behavior, so anything inside them is skippable). Files the parser rejects contribute no ranges and are otherwise reported untouched.
50
+
51
+ ### What is deliberately not skipped
52
+
53
+ Regular sig *behavior* is unaffected: sorbet-runtime evaluates a sig block lazily on the method's first call, so before this gem an uncovered sig always accompanied an uncovered method body. Skipping sigs removes the duplicate line, not the signal — the untested body still reports as a miss. One tradeoff to know about: a sig coverage miss on a *tested* method once exposed a real bug (`module_function` copies methods before the sig wrapper installs, silently disabling runtime validation). With sigs skipped, that class of bug is RuboCop's to catch (`Style/ModuleFunction: EnforcedStyle: extend_self`), not coverage's.
54
+
55
+ Skips apply through SimpleCov's directive machinery, which drives its line classification; if you enable branch coverage, branches inside skipped ranges are covered by the `branch` directive category the extension also populates.
39
56
 
40
57
  ## Requirements
41
58
 
@@ -49,15 +66,14 @@ This repo is managed with d3mlabs' `dev` tool (`dev up`, `dev test`, `dev style`
49
66
 
50
67
  ## Releasing a New Version
51
68
 
52
- 1. Update `VERSION` in `lib/simplecov/sorbet/version.rb`, run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
53
- 2. Tag and push:
54
- ```
55
- git checkout main && git pull
56
- git tag v0.1.0
57
- git push origin v0.1.0
58
- ```
69
+ From a clean checkout of `main`:
70
+
71
+ ```
72
+ dev release # auto-increments the patch version (0.1.0 → 0.1.1)
73
+ dev release 0.2.0 # explicit version
74
+ ```
59
75
 
60
- The [release workflow](.github/workflows/release.yml) validates that the tag matches `version.rb`, builds the gem, and publishes it to [rubygems.org](https://rubygems.org) via [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/).
76
+ The script ([bin/release.rb](bin/release.rb)) bumps `lib/simplecov/sorbet/version.rb` and `Gemfile.lock`, commits, tags `v<version>`, pushes, creates the GitHub release, and then watches the [release workflow](.github/workflows/release.yml) — which validates that the tag matches `version.rb`, builds the gem, and publishes it to [rubygems.org](https://rubygems.org) via [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) — until the publish succeeds.
61
77
 
62
78
  ## License
63
79
 
data/bin/release.rb ADDED
@@ -0,0 +1,225 @@
1
+ #!/bin/sh
2
+ # Use PATH ruby (rbenv) if >= 3.1, fall back to Homebrew Ruby for bootstrapping.
3
+ if command -v ruby >/dev/null 2>&1; then
4
+ if ruby -e 'exit(Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1") ? 0 : 1)' 2>/dev/null; then
5
+ exec ruby -x "$0" "$@"
6
+ fi
7
+ fi
8
+ if command -v brew >/dev/null 2>&1; then
9
+ brew_ruby="$(brew --prefix ruby 2>/dev/null)/bin/ruby"
10
+ if [ -x "$brew_ruby" ]; then
11
+ exec "$brew_ruby" -x "$0" "$@"
12
+ fi
13
+ fi
14
+ echo "release: no ruby found. Install rbenv and a Ruby version, or brew install ruby." >&2
15
+ exit 1
16
+
17
+ #!ruby
18
+ # frozen_string_literal: true
19
+
20
+ # Release a new version of simplecov-sorbet: bump version.rb + Gemfile.lock,
21
+ # commit, tag, push, create GitHub release, then watch the tag-triggered
22
+ # workflow publish the gem to rubygems.org via trusted publishing.
23
+ #
24
+ # Usage:
25
+ # ./bin/release.rb # auto-increments patch (0.1.0 → 0.1.1)
26
+ # ./bin/release.rb 0.2.0 # explicit version
27
+ # ./bin/release.rb "Release notes" # auto-increment with custom notes
28
+ # ./bin/release.rb --yes # skip the confirmation (non-interactive runs)
29
+
30
+ require "pathname"
31
+ require "open3"
32
+
33
+ GEM_NAME = "simplecov-sorbet"
34
+ GEM_ROOT = Pathname.new(File.expand_path("..", __dir__))
35
+ VERSION_FILE = GEM_ROOT.join("lib", "simplecov", "sorbet", "version.rb")
36
+ GEMFILE_LOCK = GEM_ROOT.join("Gemfile.lock")
37
+
38
+ def main
39
+ Dir.chdir(GEM_ROOT)
40
+ ensure_clean_tree!
41
+ ensure_on_main!
42
+
43
+ # --yes skips the interactive confirmation, which would otherwise hang a
44
+ # piped or backgrounded run waiting for input it can never receive.
45
+ assume_yes = !ARGV.delete("--yes").nil?
46
+
47
+ current = current_version
48
+ new_version, notes = parse_args(current)
49
+ commits = commits_since_last_tag
50
+
51
+ print_summary(current, new_version, notes, commits)
52
+ abort "Aborted." unless assume_yes || confirm?("Proceed?")
53
+ puts
54
+
55
+ step("Bumping version #{current} → #{new_version}") do
56
+ bump_version(current, new_version)
57
+ end
58
+
59
+ step("Committing and tagging v#{new_version}") do
60
+ commit_and_tag(new_version, notes)
61
+ end
62
+
63
+ step("Pushing main + tag v#{new_version}") do
64
+ push(new_version)
65
+ end
66
+
67
+ step("Creating GitHub release v#{new_version}") do
68
+ create_release(new_version, notes)
69
+ end
70
+
71
+ # The tag push triggers .github/workflows/release.yml, which publishes to
72
+ # rubygems.org via trusted publishing. Watch it so a failed publish (e.g.
73
+ # a missing trusted-publisher configuration) can't slip by silently.
74
+ step("Waiting for the rubygems publish workflow") do
75
+ watch_publish_workflow(new_version)
76
+ end
77
+
78
+ puts "v#{new_version} released: https://rubygems.org/gems/#{GEM_NAME}"
79
+ end
80
+
81
+ def parse_args(current)
82
+ case ARGV.length
83
+ when 0
84
+ [auto_increment(current), default_notes]
85
+ when 1
86
+ arg = ARGV[0]
87
+ if arg.match?(/\A\d+\.\d+\.\d+\z/)
88
+ [arg, default_notes]
89
+ else
90
+ [auto_increment(current), arg]
91
+ end
92
+ when 2
93
+ [ARGV[0], ARGV[1]]
94
+ else
95
+ abort "Usage: #{$PROGRAM_NAME} [version] [notes]"
96
+ end
97
+ end
98
+
99
+ def current_version
100
+ match = VERSION_FILE.read.match(/VERSION = "(\d+\.\d+\.\d+)"/)
101
+ abort "Could not find VERSION in #{VERSION_FILE}" unless match
102
+
103
+ match[1]
104
+ end
105
+
106
+ def auto_increment(version)
107
+ parts = version.split(".").map(&:to_i)
108
+ parts[-1] += 1
109
+ parts.join(".")
110
+ end
111
+
112
+ def default_notes
113
+ log = `git log --oneline #{latest_tag}..HEAD`.strip
114
+ return log unless log.empty?
115
+
116
+ "Maintenance release."
117
+ end
118
+
119
+ def latest_tag
120
+ `git describe --tags --abbrev=0 2>/dev/null`.strip
121
+ end
122
+
123
+ def ensure_clean_tree!
124
+ status = `git status --porcelain`.strip
125
+ return if status.empty?
126
+
127
+ abort "Working tree is not clean. Commit or stash changes first.\n#{status}"
128
+ end
129
+
130
+ def ensure_on_main!
131
+ branch = `git branch --show-current`.strip
132
+ return if branch == "main"
133
+
134
+ abort "Must be on main branch (currently on #{branch})."
135
+ end
136
+
137
+ def commits_since_last_tag
138
+ tag = latest_tag
139
+ return [] if tag.empty?
140
+
141
+ `git log --oneline #{tag}..HEAD`.strip.lines.map(&:strip)
142
+ end
143
+
144
+ def print_summary(current, new_version, notes, commits)
145
+ puts "Release: #{current} → #{new_version}"
146
+ puts "Notes: #{notes}"
147
+ puts
148
+ if commits.empty?
149
+ puts "Commits: (none since #{latest_tag})"
150
+ else
151
+ puts "Commits since #{latest_tag}:"
152
+ commits.each { |c| puts " #{c}" }
153
+ end
154
+ puts
155
+ puts "Steps:"
156
+ puts " 1. Bump version.rb + Gemfile.lock"
157
+ puts " 2. Commit + tag v#{new_version}"
158
+ puts " 3. Push main + tag to origin"
159
+ puts " 4. Create GitHub release"
160
+ puts " 5. Watch the trusted-publishing workflow push to rubygems.org"
161
+ puts
162
+ end
163
+
164
+ def confirm?(question)
165
+ print "#{question} (y/N) "
166
+ ($stdin.gets || "").strip.downcase == "y"
167
+ end
168
+
169
+ # One release step, aborting the whole release when it fails so a broken push
170
+ # can't cascade into later steps publishing state that never reached GitHub.
171
+ def step(title)
172
+ puts "--- #{title}"
173
+ yield
174
+ rescue StandardError => e
175
+ abort "Release aborted: '#{title}' failed: #{e.message}\nFix the issue, then finish the remaining steps manually."
176
+ end
177
+
178
+ def run!(*cmd)
179
+ out, err, status = Open3.capture3(*cmd)
180
+ raise "#{cmd.join(" ")} failed: #{err}" unless status.success?
181
+
182
+ out
183
+ end
184
+
185
+ def bump_version(current, new_version)
186
+ VERSION_FILE.write(VERSION_FILE.read.sub(%(VERSION = "#{current}"), %(VERSION = "#{new_version}")))
187
+
188
+ # The gem is a PATH source in its own Gemfile.lock, pinned in both the PATH
189
+ # specs block and the DEPENDENCIES section; gsub updates the pair.
190
+ lock = GEMFILE_LOCK.read
191
+ GEMFILE_LOCK.write(lock.gsub("#{GEM_NAME} (#{current})", "#{GEM_NAME} (#{new_version})"))
192
+ end
193
+
194
+ def commit_and_tag(version, notes)
195
+ run!("git", "add", VERSION_FILE.to_s, GEMFILE_LOCK.to_s)
196
+ run!("git", "commit", "-m", "Bump version to #{version}\n\n#{notes}")
197
+ run!("git", "tag", "v#{version}")
198
+ end
199
+
200
+ def push(version)
201
+ run!("git", "push", "origin", "main")
202
+ run!("git", "push", "origin", "v#{version}")
203
+ end
204
+
205
+ def create_release(version, notes)
206
+ run!("gh", "release", "create", "v#{version}",
207
+ "--title", "v#{version}", "--notes", notes)
208
+ end
209
+
210
+ # Find the workflow run the tag push triggered (retrying while GitHub
211
+ # registers it) and block until it finishes, failing loudly if it failed.
212
+ def watch_publish_workflow(version)
213
+ run_id = nil
214
+ 10.times do
215
+ run_id = `gh run list --workflow=release.yml --branch v#{version} --limit 1 --json databaseId --jq '.[0].databaseId'`.strip
216
+ break unless run_id.empty?
217
+
218
+ sleep 3
219
+ end
220
+ raise "could not find the release.yml run for v#{version}" if run_id.empty?
221
+
222
+ run!("gh", "run", "watch", run_id, "--exit-status")
223
+ end
224
+
225
+ main
data/dev.yml CHANGED
@@ -12,3 +12,6 @@ commands:
12
12
  typecheck:
13
13
  desc: Run Sorbet
14
14
  run: bundle exec srb tc
15
+ release:
16
+ desc: Release a new version (bump, tag, push; CI publishes to rubygems.org)
17
+ run: ./bin/release.rb
@@ -5,35 +5,35 @@ module SimpleCov
5
5
  module Sorbet
6
6
  # Prepended onto SimpleCov::Directive's singleton class. Directive.disabled_ranges is the one choke point both
7
7
  # SourceFile (loaded files) and LinesClassifier (tracked-but-unloaded files) consult for skip ranges, so
8
- # extending its result covers every path SimpleCov classifies lines through. Alias ranges join all three
9
- # categories: the block body is runtime-unreachable, so any line, branch, or method inside it is skippable.
8
+ # extending its result covers every path SimpleCov classifies lines through. Ignored ranges join all three
9
+ # categories: type-level constructs are not behavior, so any line, branch, or method inside them is skippable.
10
10
  module DirectiveExtension
11
11
  extend T::Sig
12
12
 
13
13
  sig { params(src_lines: T::Array[String]).returns(T::Hash[Symbol, T::Array[T::Range[Integer]]]) }
14
14
  def disabled_ranges(src_lines)
15
15
  ranges = T.let(super, T::Hash[Symbol, T::Array[T::Range[Integer]]])
16
- alias_ranges = type_alias_ranges(src_lines.join)
17
- return ranges if alias_ranges.empty?
16
+ sorbet_ranges = ignored_ranges(src_lines.join)
17
+ return ranges if sorbet_ranges.empty?
18
18
 
19
- ranges.each_value { |category_ranges| category_ranges.concat(alias_ranges) }
19
+ ranges.each_value { |category_ranges| category_ranges.concat(sorbet_ranges) }
20
20
  ranges
21
21
  end
22
22
 
23
23
  private
24
24
 
25
- # Scans +source+ for +T.type_alias+ blocks. Unparseable source yields no ranges: coverage annotation must
26
- # never take down a suite's reporting, and a file Ruby executed but this parser rejects has no aliases we
27
- # could trust anyway. The rescue is deliberately wide — Prism parses most broken source tolerantly, and what
28
- # escapes is not Parser::SyntaxError but arbitrary errors from the whitequark builder choking on
29
- # error-recovered trees (e.g. NoMethodError in join_exprs).
25
+ # Scans +source+ for type-level Sorbet constructs (see IgnoredRanges). Unparseable source yields no ranges:
26
+ # coverage annotation must never take down a suite's reporting, and a file Ruby executed but this parser
27
+ # rejects has no constructs we could trust anyway. The rescue is deliberately wide — Prism parses most broken
28
+ # source tolerantly, and what escapes is not Parser::SyntaxError but arbitrary errors from the whitequark
29
+ # builder choking on error-recovered trees (e.g. NoMethodError in join_exprs).
30
30
  #
31
31
  # @param source [String] The Ruby source to scan.
32
32
  #
33
- # @return [Array<Range<Integer>>] One line range per alias block, in source order.
33
+ # @return [Array<Range<Integer>>] One line range per ignored construct, in source order.
34
34
  sig { params(source: String).returns(T::Array[T::Range[Integer]]) }
35
- def type_alias_ranges(source)
36
- TypeAliasRanges.new.run(source_parser.parse(source)).ranges
35
+ def ignored_ranges(source)
36
+ IgnoredRanges.new.run(source_parser.parse(source)).ranges
37
37
  rescue StandardError
38
38
  []
39
39
  end
@@ -0,0 +1,86 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Sorbet
6
+ # Read-only analysis pass collecting the line ranges of type-level Sorbet constructs coverage should ignore.
7
+ # Detection is purely syntactic — this gem never loads Sorbet's type system. Three constructs are collected:
8
+ #
9
+ # - +T.type_alias+ blocks: sorbet-runtime resolves aliases lazily and collection checks are shallow, so a
10
+ # multi-line alias body never executes and reads as a permanent coverage miss.
11
+ # - +sig+ blocks (bare or with a receiver, e.g. +T::Sig::WithoutRuntime.sig+): sigs are type metadata whose
12
+ # correctness +srb tc+ owns; coverage should measure behavior. Only multi-line blocks are collected — a
13
+ # single-line +sig { ... }+ occupies the send's own line, which executes at load.
14
+ # - +T.absurd+ sends: unreachable by definition when exhaustiveness holds, so in correct code the line is a
15
+ # permanent coverage miss.
16
+ class IgnoredRanges < ASTTransform::AbstractAnalysis
17
+ extend T::Sig
18
+
19
+ # Structural patterns for the +T+ receiver (node equality ignores source locations): +T+ and its explicit
20
+ # top-level form +::T+.
21
+ T_RECEIVERS = T.let(
22
+ [
23
+ s(:const, nil, :T),
24
+ s(:const, s(:cbase), :T),
25
+ ].freeze,
26
+ T::Array[Parser::AST::Node],
27
+ )
28
+
29
+ # One line range per ignored construct, in source order.
30
+ sig { returns(T::Array[T::Range[Integer]]) }
31
+ attr_reader :ranges
32
+
33
+ sig { void }
34
+ def initialize
35
+ @ranges = T.let([], T::Array[T::Range[Integer]])
36
+ super
37
+ end
38
+
39
+ # Collects the send's full expression range when it is a +T.absurd+ call.
40
+ #
41
+ # @param node [Parser::AST::Node] The send node being visited.
42
+ #
43
+ # @return [Parser::AST::Node] The rebuilt node (discarded by AbstractAnalysis#run).
44
+ sig { params(node: Parser::AST::Node).returns(Parser::AST::Node) }
45
+ def on_send(node)
46
+ receiver, method_name = node.children
47
+ @ranges << line_range(node) if method_name == :absurd && T_RECEIVERS.include?(receiver)
48
+
49
+ super
50
+ end
51
+
52
+ # Collects the block's full expression range when the block's send is a +T.type_alias+ or a multi-line +sig+.
53
+ #
54
+ # @param node [Parser::AST::Node] The block node being visited.
55
+ #
56
+ # @return [Parser::AST::Node] The rebuilt node (discarded by AbstractAnalysis#run).
57
+ sig { params(node: Parser::AST::Node).returns(Parser::AST::Node) }
58
+ def on_block(node)
59
+ receiver, method_name = node.children.first.children
60
+ range = line_range(node)
61
+
62
+ case method_name
63
+ when :type_alias
64
+ @ranges << range if T_RECEIVERS.include?(receiver)
65
+ when :sig
66
+ # Any receiver qualifies: bare sig blocks and forms like T::Sig::WithoutRuntime.sig are all sigs. A
67
+ # non-Sorbet DSL also named `sig` would be over-matched, an accepted risk documented in the README.
68
+ @ranges << range if range.first < range.last
69
+ end
70
+
71
+ super
72
+ end
73
+
74
+ private
75
+
76
+ # @param node [Parser::AST::Node] The node whose source span to convert.
77
+ #
78
+ # @return [Range<Integer>] The node's 1-indexed first-to-last line range.
79
+ sig { params(node: Parser::AST::Node).returns(T::Range[Integer]) }
80
+ def line_range(node)
81
+ expression = node.loc.expression
82
+ expression.first_line..expression.last_line
83
+ end
84
+ end
85
+ end
86
+ end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module SimpleCov
5
5
  module Sorbet
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
@@ -7,13 +7,13 @@ require "ast_transform"
7
7
  require "ast_transform/abstract_analysis"
8
8
 
9
9
  require "simplecov/sorbet/version"
10
- require "simplecov/sorbet/type_alias_ranges"
10
+ require "simplecov/sorbet/ignored_ranges"
11
11
  require "simplecov/sorbet/directive_extension"
12
12
 
13
13
  module SimpleCov
14
- # SimpleCov extension for Sorbet codebases: skips constructs Sorbet makes runtime-unreachable by design, starting
15
- # with multi-line +T.type_alias+ blocks. Requiring this file installs the extension; Module#prepend is idempotent,
16
- # so requiring it more than once is harmless.
14
+ # SimpleCov extension for Sorbet codebases: skips type-level Sorbet constructs coverage should not measure —
15
+ # multi-line +T.type_alias+ blocks, +sig+ blocks, and +T.absurd+ sends. Requiring this file installs the
16
+ # extension; Module#prepend is idempotent, so requiring it more than once is harmless.
17
17
  module Sorbet
18
18
  end
19
19
  end
@@ -10,10 +10,10 @@ Gem::Specification.new do |spec|
10
10
  spec.authors = ["Jean-Philippe Duchesne"]
11
11
  spec.email = ["jpduchesne89@gmail.com"]
12
12
 
13
- spec.summary = "SimpleCov extension that skips runtime-unreachable Sorbet constructs (T.type_alias blocks)."
14
- spec.description = "Multi-line T.type_alias blocks never execute (sorbet-runtime resolves aliases lazily), " \
15
- "so SimpleCov reports them as uncovered. This extension detects them syntactically and feeds their line " \
16
- "ranges into SimpleCov's skip machinery."
13
+ spec.summary = "SimpleCov extension that skips type-level Sorbet constructs (T.type_alias, sig, T.absurd)."
14
+ spec.description = "Type-level Sorbet constructs read as coverage misses: multi-line T.type_alias and sig " \
15
+ "blocks evaluate lazily or never, and T.absurd is unreachable by definition. This extension detects them " \
16
+ "syntactically and feeds their line ranges into SimpleCov's skip machinery."
17
17
  spec.homepage = "https://github.com/d3mlabs/simplecov-sorbet"
18
18
  spec.license = "MIT"
19
19
  spec.files = %x(git ls-files -z).split("\x0").reject do |f|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simplecov-sorbet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean-Philippe Duchesne
@@ -52,9 +52,10 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
- description: Multi-line T.type_alias blocks never execute (sorbet-runtime resolves
56
- aliases lazily), so SimpleCov reports them as uncovered. This extension detects
57
- them syntactically and feeds their line ranges into SimpleCov's skip machinery.
55
+ description: 'Type-level Sorbet constructs read as coverage misses: multi-line T.type_alias
56
+ and sig blocks evaluate lazily or never, and T.absurd is unreachable by definition.
57
+ This extension detects them syntactically and feeds their line ranges into SimpleCov''s
58
+ skip machinery.'
58
59
  email:
59
60
  - jpduchesne89@gmail.com
60
61
  executables: []
@@ -72,13 +73,14 @@ files:
72
73
  - LICENSE.txt
73
74
  - README.md
74
75
  - Rakefile
76
+ - bin/release.rb
75
77
  - bin/tapioca
76
78
  - dependencies.rb
77
79
  - dev.yml
78
80
  - lib/simplecov-sorbet.rb
79
81
  - lib/simplecov/sorbet.rb
80
82
  - lib/simplecov/sorbet/directive_extension.rb
81
- - lib/simplecov/sorbet/type_alias_ranges.rb
83
+ - lib/simplecov/sorbet/ignored_ranges.rb
82
84
  - lib/simplecov/sorbet/version.rb
83
85
  - simplecov-sorbet.gemspec
84
86
  homepage: https://github.com/d3mlabs/simplecov-sorbet
@@ -103,6 +105,6 @@ requirements: []
103
105
  rubygems_version: 3.5.22
104
106
  signing_key:
105
107
  specification_version: 4
106
- summary: SimpleCov extension that skips runtime-unreachable Sorbet constructs (T.type_alias
107
- blocks).
108
+ summary: SimpleCov extension that skips type-level Sorbet constructs (T.type_alias,
109
+ sig, T.absurd).
108
110
  test_files: []
@@ -1,49 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- module SimpleCov
5
- module Sorbet
6
- # Read-only analysis pass collecting the line ranges of +T.type_alias+ blocks. Their bodies are
7
- # runtime-unreachable by design: sorbet-runtime resolves aliases lazily and collection checks are shallow, so a
8
- # multi-line alias block never executes and reads as a permanent coverage miss. Detection is purely syntactic —
9
- # this gem never loads Sorbet's type system.
10
- class TypeAliasRanges < ASTTransform::AbstractAnalysis
11
- extend T::Sig
12
-
13
- # Structural patterns for the alias send (node equality ignores source locations): +T.type_alias+ and its
14
- # explicit top-level form +::T.type_alias+.
15
- TYPE_ALIAS_SENDS = T.let(
16
- [
17
- s(:send, s(:const, nil, :T), :type_alias),
18
- s(:send, s(:const, s(:cbase), :T), :type_alias),
19
- ].freeze,
20
- T::Array[Parser::AST::Node],
21
- )
22
-
23
- # One line range per alias block, in source order.
24
- sig { returns(T::Array[T::Range[Integer]]) }
25
- attr_reader :ranges
26
-
27
- sig { void }
28
- def initialize
29
- @ranges = T.let([], T::Array[T::Range[Integer]])
30
- super
31
- end
32
-
33
- # Collects the block's full expression range when the block's send is a +T.type_alias+.
34
- #
35
- # @param node [Parser::AST::Node] The block node being visited.
36
- #
37
- # @return [Parser::AST::Node] The rebuilt node (discarded by AbstractAnalysis#run).
38
- sig { params(node: Parser::AST::Node).returns(Parser::AST::Node) }
39
- def on_block(node)
40
- if TYPE_ALIAS_SENDS.include?(node.children.first)
41
- expression = node.loc.expression
42
- @ranges << (expression.first_line..expression.last_line)
43
- end
44
-
45
- super
46
- end
47
- end
48
- end
49
- end