steep-relaxed 2.0.0.0 → 2.1.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/CLAUDE.md +2 -2
  4. data/README.md +3 -3
  5. data/Rakefile +352 -143
  6. data/Steepfile +2 -0
  7. data/bin/shape_warmup_bench.rb +402 -0
  8. data/bin/steep-check.rb +4 -4
  9. data/bin/subtyping_cache_bench.rb +436 -0
  10. data/bin/typecheck_bench.rb +145 -0
  11. data/doc/release.md +264 -0
  12. data/lib/steep/ast/types/helper.rb +0 -1
  13. data/lib/steep/ast/types/literal.rb +1 -1
  14. data/lib/steep/ast/types/name.rb +1 -1
  15. data/lib/steep/cli.rb +32 -5
  16. data/lib/steep/daemon/server.rb +1 -3
  17. data/lib/steep/daemon.rb +49 -9
  18. data/lib/steep/diagnostic/ruby.rb +4 -4
  19. data/lib/steep/drivers/check.rb +0 -1
  20. data/lib/steep/drivers/checkfile.rb +4 -12
  21. data/lib/steep/drivers/init.rb +4 -3
  22. data/lib/steep/drivers/langserver.rb +34 -5
  23. data/lib/steep/drivers/query.rb +40 -2
  24. data/lib/steep/drivers/stats.rb +0 -1
  25. data/lib/steep/drivers/worker.rb +0 -7
  26. data/lib/steep/interface/builder.rb +194 -28
  27. data/lib/steep/interface/shape.rb +28 -14
  28. data/lib/steep/node_helper.rb +2 -2
  29. data/lib/steep/server/base_worker.rb +27 -30
  30. data/lib/steep/server/change_buffer.rb +0 -32
  31. data/lib/steep/server/command_socket.rb +181 -0
  32. data/lib/steep/server/custom_methods.rb +22 -22
  33. data/lib/steep/server/goto_resolver.rb +200 -0
  34. data/lib/steep/server/interaction_worker.rb +32 -12
  35. data/lib/steep/server/master.rb +709 -261
  36. data/lib/steep/server/rbs_entry_builder.rb +237 -0
  37. data/lib/steep/server/type_check_controller.rb +73 -27
  38. data/lib/steep/server/type_check_database.rb +406 -0
  39. data/lib/steep/server/type_check_worker.rb +118 -352
  40. data/lib/steep/server/worker_process.rb +8 -22
  41. data/lib/steep/services/hover_provider/content.rb +1 -1
  42. data/lib/steep/services/hover_provider/ruby.rb +1 -1
  43. data/lib/steep/services/signature_help_provider.rb +1 -1
  44. data/lib/steep/services/stats_calculator.rb +16 -35
  45. data/lib/steep/services/symbol_provider.rb +191 -0
  46. data/lib/steep/services/type_check_service.rb +10 -21
  47. data/lib/steep/source.rb +9 -10
  48. data/lib/steep/subtyping/cache.rb +16 -4
  49. data/lib/steep/subtyping/check.rb +83 -16
  50. data/lib/steep/subtyping/stats.rb +356 -0
  51. data/lib/steep/tagged_logging.rb +4 -1
  52. data/lib/steep/type_construction.rb +39 -17
  53. data/lib/steep/type_inference/case_when.rb +2 -2
  54. data/lib/steep/type_inference/logic_type_interpreter.rb +6 -3
  55. data/lib/steep/type_inference/send_args.rb +9 -5
  56. data/lib/steep/typing.rb +3 -3
  57. data/lib/steep/version.rb +1 -1
  58. data/lib/steep.rb +6 -1
  59. data/steep-relaxed.gemspec +2 -8
  60. metadata +15 -6
  61. data/lib/steep/services/goto_service.rb +0 -644
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06a874321a0387affa79acdee9e6b5dee0541ac453792c7e63fef1e2718f2ce9
4
- data.tar.gz: 4566b3de6706976eb3327df058f805b1f9a6ef75bf2acd57a7a23a6a96f4e396
3
+ metadata.gz: 62c5e1172e3ea8417b507a37c99cdd291db4ee4ab223e84939a47d7f6287800b
4
+ data.tar.gz: 740ec1d1b15cdfcf9e102379ec0b3a55f68dc710a7ab83fbaec43025f86bd506
5
5
  SHA512:
6
- metadata.gz: 751a368c57dfe951ab51e7b838f936db50450cbc9b0841e2a6a3a243eb4d8db9a5de5e8911c818d240f964fa2302cc075ddabce40eae248d6d466af02119e475
7
- data.tar.gz: 8bd34bedb64e9454d3bfbb9dfd9e8cd92924a212bcd242bcd2882b12272958c420d0dc99297b389cd4f78cbaa993151de2ccdf431106f32ef0f7382d04da011c
6
+ metadata.gz: d1fbbb8bfb247b14a98a31a3be90ab92303452cbf60a0f9317ca7fe6e6a58351ba59ede725659d0e1e7b9fe284976f5c2bd561ea89966a9d8e4e71f8b0cd86e0
7
+ data.tar.gz: e52922fcc78b233f8adaf23a771065dc62895367cf4a053b87f405e2ff1f6fcb188c94e8ee863921b2c4a763edc1203651a17c0790dc38897d1c632afdcb349b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.1.0 (2026-08-27)
4
+
5
+ Steep 2.1 opens the running server to commands other than the editor's. `steep langserver` now binds
6
+ the per-project UNIX socket that `steep server` serves, so `steep check` and `steep query` reach the
7
+ language server the editor is already running instead of needing a daemon of their own, and the new
8
+ `steep query diagnostics` reads the diagnostics that server has already computed rather than starting
9
+ a type check of its own. Both are aimed at tools that edit files on disk and want fast feedback,
10
+ coding agents in particular.
11
+
12
+ The type checker gains support for Ruby 3.4's `it` block parameter, and RBS is updated to 4.2.0.
13
+
14
+ ### Type checker core
15
+
16
+ * Infer tuple types for array literals against pair interface hints like `Hash::_Pair` ([#2253](https://github.com/soutaro/steep/pull/2253))
17
+ * Accept a type alias as the type of a lambda's block parameter ([#2263](https://github.com/soutaro/steep/pull/2263))
18
+ * Build expensive log tags only when something is logged ([#2271](https://github.com/soutaro/steep/pull/2271))
19
+ * Accept block-pass arguments for optional blocks ([#2261](https://github.com/soutaro/steep/pull/2261))
20
+ * feat: support the `it` block parameter ([#2238](https://github.com/soutaro/steep/pull/2238))
21
+ * Fix `UnreachableBranch` on `==` against literal supertype (`Symbol` / `Integer` / `String`) ([#2223](https://github.com/soutaro/steep/pull/2223))
22
+
23
+ ### Commandline tool
24
+
25
+ * Stop `steep query definition` from hanging on inline sources ([#2276](https://github.com/soutaro/steep/pull/2276))
26
+ * Serve steep query/check through the language server's UNIX socket ([#2273](https://github.com/soutaro/steep/pull/2273))
27
+ * Add `steep query diagnostics` ([#2274](https://github.com/soutaro/steep/pull/2274))
28
+ * Point the Steepfile template, README and CLAUDE.md at rbs collection ([#2269](https://github.com/soutaro/steep/pull/2269))
29
+
30
+ ### Miscellaneous
31
+
32
+ * Update RBS to 4.2.0 ([#2277](https://github.com/soutaro/steep/pull/2277))
33
+ * Fix `bin/steep-check.rb` for the current API ([#2270](https://github.com/soutaro/steep/pull/2270))
34
+ * Update RBS to 4.2.0.pre.1 ([#2267](https://github.com/soutaro/steep/pull/2267))
35
+ * Fix Ruby warnings printed while running tests ([#2266](https://github.com/soutaro/steep/pull/2266))
36
+ * Run test subprocesses outside the parent Bundler env ([#2225](https://github.com/soutaro/steep/pull/2225))
37
+
3
38
  ## 2.0.0 (2026-04-14)
4
39
 
5
40
  ### Summary
data/CLAUDE.md CHANGED
@@ -98,8 +98,8 @@ bundle exec rake build
98
98
  **Steepfile**: Defines type checking targets
99
99
  - `check`: Directories to type check
100
100
  - `signature`: RBS signature directories
101
- - `library`: Standard library dependencies
102
- - `collection_config`: External RBS definitions
101
+ - `library`: RBSs that rbs collection doesn't manage
102
+ - `collection_config`: Path to `rbs_collection.yaml` (the project root one is read by default)
103
103
  - Multiple targets supported (app, test, bin)
104
104
 
105
105
  **RBS Collection**: Manages external type definitions via `rbs_collection.steep.yaml`
data/README.md CHANGED
@@ -34,11 +34,11 @@ Edit the `Steepfile`:
34
34
  target :app do
35
35
  check "lib"
36
36
  signature "sig"
37
-
38
- library "pathname"
39
37
  end
40
38
  ```
41
39
 
40
+ RBSs for the gems in `Gemfile.lock` are loaded via [rbs collection](https://github.com/ruby/rbs/blob/master/docs/collection.md), which you set up with `rbs collection init` and `rbs collection install`. Add a `library` call only for an RBS that rbs collection doesn't manage.
41
+
42
42
  ### 1. Declare Types
43
43
 
44
44
  Declare types in `.rbs` files in `sig` directory.
@@ -253,7 +253,7 @@ Use `bundle exec rake -T` to see all available tasks.
253
253
 
254
254
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
255
255
 
256
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
256
+ To install this gem onto your local machine, run `bundle exec rake install`. Releases are cut by the `Release gem` workflow rather than from a working copy -- see [doc/release.md](doc/release.md).
257
257
 
258
258
  ## Contributing
259
259
 
data/Rakefile CHANGED
@@ -36,204 +36,413 @@ end
36
36
  # Rake::Task[:"release:release-prs"].invoke
37
37
  # end
38
38
 
39
- desc "Generate changelog template from GH pull requests"
40
- task :changelog do
41
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
42
- major = major.to_i
43
- minor = minor.to_i
44
- patch = patch.to_i
45
-
46
- if patch == 0
47
- milestone = "Steep #{major}.#{minor}"
48
- else
49
- milestone = "Steep #{major}.#{minor}.x"
50
- end
39
+ # Pull requests with one of these labels are omitted from the changelog.
40
+ CHANGELOG_SKIP_LABELS = ["skip-changelog"]
41
+
42
+ # The tags a release proper starts *after*, rather than at.
43
+ CHANGELOG_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"]
44
+
45
+ # Resolves the commit-ish the changelog of the release being prepared starts from.
46
+ #
47
+ # `version` is a version number, a tag, or any commit-ish. When it is omitted, the base follows
48
+ # `Steep::VERSION`:
49
+ #
50
+ # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag.
51
+ # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags
52
+ # in between and starts from the previous release proper.
53
+ #
54
+ # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a
55
+ # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version
56
+ # explicitly overrides all of it.
57
+ #
58
+ def changelog_base(version)
59
+ require "open3"
51
60
 
52
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
61
+ from =
62
+ if version
63
+ # `2.1.0` and `v2.1.0` both mean the tag `v2.1.0`, while `master` or a SHA is used as is.
64
+ version.match?(/\A\d/) ? "v#{version}" : version
65
+ else
66
+ command = ["git", "describe", "--tags", "--match", "v*", "--abbrev=0"]
67
+ unless Gem::Version.new(Steep::VERSION).prerelease?
68
+ CHANGELOG_PRERELEASE_TAGS.each { |glob| command.push("--exclude", glob) }
69
+ end
53
70
 
54
- command = [
55
- "gh",
56
- "pr",
57
- "list",
58
- "--json",
59
- "url,title,number",
60
- "--limit=100",
61
- "--search" ,
62
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
63
- ]
71
+ output, status = Open3.capture2(*command)
72
+ raise "🚨 Cannot detect the tag the changelog starts from. Give the previous version explicitly." unless status.success?
73
+ output.chomp
74
+ end
64
75
 
65
- require "open3"
66
- output, status = Open3.capture2(*command)
67
- raise status.inspect unless status.success?
76
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}")
77
+ raise "🚨 No such commit-ish: `#{from}`" unless status.success?
68
78
 
79
+ from
80
+ end
81
+
82
+ # Runs a GraphQL query against the repository of the working directory.
83
+ #
84
+ # `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name`
85
+ # variables. Returns the contents of `data.repository`.
86
+ #
87
+ def changelog_graphql(body)
88
+ require "open3"
69
89
  require "json"
70
- json = JSON.parse(output, symbolize_names: true)
71
90
 
72
- unless json.empty?
73
- puts
74
- json.each do |line|
75
- puts "* #{line[:title]} ([##{line[:number]}](#{line[:url]}))"
91
+ @changelog_repository ||=
92
+ begin
93
+ output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
94
+ raise status.inspect unless status.success?
95
+ output.chomp.split("/", 2)
76
96
  end
77
- else
78
- puts " (🤑 There is no *unreleased* pull request associated to the milestone.)"
79
- end
97
+ owner, name = @changelog_repository
98
+
99
+ query = <<~GRAPHQL
100
+ query($owner: String!, $name: String!) {
101
+ repository(owner: $owner, name: $name) {
102
+ #{body}
103
+ }
104
+ }
105
+ GRAPHQL
106
+
107
+ output, status = Open3.capture2(
108
+ "gh", "api", "graphql",
109
+ "-f", "query=#{query}",
110
+ "-f", "owner=#{owner}",
111
+ "-f", "name=#{name}",
112
+ binmode: true
113
+ )
114
+ raise status.inspect unless status.success?
115
+
116
+ # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without
117
+ # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions.
118
+ JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
80
119
  end
81
120
 
82
- namespace :release do
83
- desc "Ensure release note"
84
- task :note do
85
- version = Gem::Version.new(Steep::VERSION)
86
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
87
- major = major.to_i
88
- minor = minor.to_i
89
- patch = patch.to_i
121
+ # Lists the commits between `from` and `HEAD`, newest first.
122
+ #
123
+ def changelog_commits(from)
124
+ require "open3"
90
125
 
91
- wiki_url = "https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor}"
126
+ output, status = Open3.capture2("git", "log", "--format=%H", "#{from}..HEAD")
127
+ raise status.inspect unless status.success?
92
128
 
93
- puts "🧐 Checking if Release note page already exists..."
129
+ output.lines.map(&:chomp).reject(&:empty?)
130
+ end
94
131
 
95
- unless `curl --silent -o/dev/null --head #{wiki_url} -w '%{http_code}'` == "200"
96
- pre_flag = version.prerelease? ? " --pre" : ""
97
- pre_requirement = version.prerelease? ? ", '~> #{major}.#{minor}.#{patch}.#{pre}'" : ""
132
+ # What `git cherry-pick -x` appends to the message of the commit it creates.
133
+ CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/
134
+
135
+ # Maps the commits that record where they were cherry-picked from to that commit.
136
+ #
137
+ # A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit
138
+ # itself, that leads to the pull request the change was written and reviewed in. Without this a
139
+ # backported change is attributed to the pull request that carried the backport, which says
140
+ # nothing about the change and is the same for every commit it brought over.
141
+ #
142
+ # A commit backported twice -- the development line, then a release branch -- carries one line
143
+ # per hop, appended in order, so the first one is where the change started.
144
+ #
145
+ def changelog_origins(commits)
146
+ return {} if commits.empty?
98
147
 
99
- puts "----"
148
+ require "open3"
100
149
 
101
- puts <<~PREFIX if patch
102
- **The latest version of Steep #{major}.#{minor} is `#{pre}`.**
150
+ # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message --
151
+ # which can contain anything, including what this format looks like -- from being read as the
152
+ # format itself.
153
+ output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true)
154
+ raise status.inspect unless status.success?
103
155
 
104
- PREFIX
156
+ # Commit messages are UTF-8, while the default external encoding follows the locale. Without
157
+ # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions.
158
+ output.force_encoding(Encoding::UTF_8)
105
159
 
106
- puts <<~TEMPLATE
107
- Some of the highlights in Steep #{major}.#{minor} are:
160
+ output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins|
161
+ commit = commit.to_s.strip
162
+ next if commit.empty?
108
163
 
109
- * New feature 1 (URL)
110
- * New feature 2 (URL)
111
- * New feature 3 (URL)
164
+ origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next
165
+ origins[commit] = origin
166
+ end
167
+ end
112
168
 
113
- You can install it with `$ gem install steep#{pre_flag}` or using Bundler.
169
+ # Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge
170
+ # commit, squash, or rebase -- is handled without parsing commit messages.
171
+ #
172
+ # Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no
173
+ # merged pull request for, including the ones it does not know at all.
174
+ #
175
+ def changelog_associated_pull_requests(oids)
176
+ oids.uniq.each_slice(50).each_with_object({}) do |slice, found|
177
+ aliases = slice.map.with_index do |oid, index|
178
+ <<~GRAPHQL
179
+ c#{index}: object(oid: "#{oid}") {
180
+ ... on Commit {
181
+ associatedPullRequests(first: 10) {
182
+ nodes {
183
+ number title url merged
184
+ labels(first: 100) { nodes { name } }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ GRAPHQL
190
+ end
114
191
 
115
- ```rb
116
- gem 'steep', require: false#{pre_requirement}
117
- ```
192
+ response = changelog_graphql(aliases.join("\n"))
118
193
 
119
- See the [CHANGELOG](https://github.com/soutaro/steep/blob/master/CHANGELOG.md) for the details.
194
+ slice.each_with_index do |oid, index|
195
+ nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || []
120
196
 
121
- ## New feature 1
197
+ found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr|
198
+ { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } }
199
+ end
200
+ end
201
+ end
202
+ end
122
203
 
123
- ## New feature 2
204
+ # Finds the pull requests the commits came from, keeping the order of `commits`.
205
+ #
206
+ # Returns the pull requests for the changelog and the ones omitted by `skip_labels`.
207
+ #
208
+ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
209
+ origins = changelog_origins(commits)
210
+ found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit })
211
+
212
+ # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
213
+ # default branch without a pull request -- falls back to the commit in this history, which is
214
+ # at least the backport that brought it here.
215
+ fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? }
216
+ found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty?
217
+
218
+ pull_requests = {}
219
+ skipped = {}
220
+
221
+ commits.each do |commit|
222
+ prs = found.fetch(origins[commit] || commit, [])
223
+ prs = found.fetch(commit, []) if prs.empty?
224
+
225
+ prs.each do |pr|
226
+ if (pr[:labels] & skip_labels).empty?
227
+ pull_requests[pr[:number]] ||= pr
228
+ else
229
+ skipped[pr[:number]] ||= pr
230
+ end
231
+ end
232
+ end
124
233
 
125
- ## New feature 3
234
+ [pull_requests.values, skipped.values]
235
+ end
126
236
 
127
- ## Diagnostics updates
237
+ # Fetches the details that help classifying the pull requests: the changed files and the body.
238
+ #
239
+ def changelog_pull_request_details(pull_requests)
240
+ pull_requests.each_slice(50).flat_map do |slice|
241
+ aliases = slice.map do |pr|
242
+ <<~GRAPHQL
243
+ p#{pr[:number]}: pullRequest(number: #{pr[:number]}) {
244
+ body
245
+ author { login }
246
+ files(first: 100) {
247
+ nodes { path }
248
+ pageInfo { hasNextPage }
249
+ }
250
+ }
251
+ GRAPHQL
252
+ end
128
253
 
129
- ## Updating Steep
254
+ details = changelog_graphql(aliases.join("\n"))
130
255
 
131
- TEMPLATE
132
- puts "----"
133
- puts
256
+ slice.map do |pr|
257
+ detail = details[:"p#{pr[:number]}"] or next pr
134
258
 
135
- puts " ⏩️ Create the release note with the template: #{wiki_url}"
136
- else
137
- if patch == 0 || version.prerelease?
138
- puts " ⏩️ Open the release note and update it at: #{wiki_url}"
139
- else
140
- puts " ✅ Release note is ready!"
141
- end
259
+ pr.merge(
260
+ author: detail.dig(:author, :login),
261
+ # The body is a hint for writing the changelog, not a copy source. Keep it short.
262
+ body: detail[:body].to_s.strip.slice(0, 1000),
263
+ files: detail.dig(:files, :nodes).map { |file| file[:path] },
264
+ files_truncated: detail.dig(:files, :pageInfo, :hasNextPage)
265
+ )
142
266
  end
143
- puts
144
267
  end
268
+ end
145
269
 
146
- desc "Create GitHub release automatically"
147
- task :github do
148
- version = Gem::Version.new(Steep::VERSION)
149
- major, minor, patch, *_ = Steep::VERSION.split(".")
150
- major = major.to_i
151
- minor = minor.to_i
152
- patch = patch.to_i
153
-
154
- puts "✏️ Making a draft release on GitHub..."
270
+ # Reports the pull requests omitted by their label, so that they do not disappear silently.
271
+ #
272
+ def warn_skipped_pull_requests(skipped, skip_labels)
273
+ return if skipped.empty?
155
274
 
156
- content = File.read(File.join(__dir__, "CHANGELOG.md"))
157
- changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0]
158
- changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
275
+ numbers = skipped.map { |pr| "##{pr[:number]}" }
276
+ numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20
159
277
 
160
- notes = <<NOTES
161
- [Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
278
+ $stderr.puts
279
+ $stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
280
+ end
162
281
 
163
- #{changelog}
164
- NOTES
282
+ # Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
283
+ #
284
+ # The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
285
+ # piped to another command: `rake gem:changelog | pbcopy`
286
+ #
287
+ def print_changelog(from, skip_labels: CHANGELOG_SKIP_LABELS)
288
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
289
+
290
+ commits = changelog_commits(from)
291
+ if commits.empty?
292
+ $stderr.puts " (🤔 There is no commit after `#{from}`.)"
293
+ return
294
+ end
165
295
 
166
- command = [
167
- "gh",
168
- "release",
169
- "create",
170
- "--draft",
171
- "v#{Steep::VERSION}",
172
- "--title=#{Steep::VERSION}",
173
- "--notes=#{notes}"
174
- ]
296
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
175
297
 
176
- if version.prerelease?
177
- command << "--prerelease"
298
+ if pull_requests.empty?
299
+ $stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)"
300
+ else
301
+ $stderr.puts
302
+ pull_requests.each do |pr|
303
+ puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))"
178
304
  end
305
+ $stdout.flush
306
+ end
179
307
 
180
- require "open3"
181
- output, status = Open3.capture2(*command)
182
- if status.success?
183
- puts " ⏩️ Done! Open #{output.chomp} and publish the release!"
184
- puts
308
+ warn_skipped_pull_requests(skipped, skip_labels)
309
+ end
310
+
311
+ # Prints the same pull requests as `print_changelog` as JSON, with the details that help
312
+ # classifying them into the sections of CHANGELOG.md.
313
+ #
314
+ # This is the input for the release automation, so it always prints a valid JSON document.
315
+ #
316
+ def print_changelog_json(from, skip_labels: CHANGELOG_SKIP_LABELS)
317
+ require "json"
318
+
319
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
320
+
321
+ commits = changelog_commits(from)
322
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
323
+ pull_requests = changelog_pull_request_details(pull_requests)
324
+
325
+ $stderr.puts " (📋 #{pull_requests.size} pull request(s))"
326
+
327
+ puts JSON.pretty_generate(
328
+ {
329
+ from: from,
330
+ to: "HEAD",
331
+ pull_requests: pull_requests,
332
+ skipped: skipped
333
+ }
334
+ )
335
+ $stdout.flush
336
+
337
+ warn_skipped_pull_requests(skipped, skip_labels)
338
+ end
339
+
340
+ namespace :gem do
341
+ desc "Generate changelog template from GH pull requests merged since the previous release"
342
+ task :changelog, [:version] do |_task, args|
343
+ print_changelog(changelog_base(args[:version]))
344
+ end
345
+
346
+ namespace :changelog do
347
+ desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each"
348
+ task :json, [:version] do |_task, args|
349
+ print_changelog_json(changelog_base(args[:version]))
185
350
  end
186
351
  end
187
352
 
188
- desc "Add `Released` labels to pull requests associated to the version"
189
- task "release-prs" do
190
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
191
- major = major.to_i
192
- minor = minor.to_i
193
- patch = patch.to_i
353
+ # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The
354
+ # `.dev.N` ones are cut from the development line for people who need a specific
355
+ # change early; they are not written up in the changelog, so there are no notes to
356
+ # publish and nothing worth announcing.
357
+ def dev_release?(version)
358
+ Gem::Version.new(version).segments.include?("dev")
359
+ end
360
+
361
+ # The body of the topmost section of CHANGELOG.md, which is the release being
362
+ # prepared, minus its own heading.
363
+ #
364
+ # The encoding is explicit because the default external encoding follows the
365
+ # locale, and the changelog is not ASCII.
366
+ #
367
+ def changelog_section(version)
368
+ content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
369
+ section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
370
+ heading, _, body = section.partition("\n")
371
+ heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}"
372
+ body.strip
373
+ end
374
+
375
+ desc "Check that the working tree is ready to be released as the given version"
376
+ task :check_release, [:version] do |_task, args|
377
+ version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[2.1.0]'`"
378
+ Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number."
379
+
380
+ # The version being released and the version the commit declares are stated
381
+ # separately -- one by whoever starts the release, one by the commit itself --
382
+ # so that releasing the wrong commit, or releasing the right one under the wrong
383
+ # name, fails here rather than on RubyGems.
384
+ version == Steep::VERSION or
385
+ raise "🚨 Releasing #{version}, but this commit declares `Steep::VERSION = #{Steep::VERSION.inspect}`."
194
386
 
195
- if patch == 0
196
- milestone = "Steep #{major}.#{minor}"
387
+ if dev_release?(version)
388
+ puts " #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked."
197
389
  else
198
- milestone = "Steep #{major}.#{minor}.x"
390
+ changelog_section(version)
391
+ puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it."
199
392
  end
393
+ end
394
+
395
+ desc "Create and push the `vX.Y.Z` tag for Steep::VERSION"
396
+ task :tag do
397
+ tag = "v#{Steep::VERSION}"
398
+
399
+ # Annotated, so that the tag carries its own author and date rather than
400
+ # borrowing the tagged commit's.
401
+ sh "git", "tag", "--annotate", "--message", "Steep #{Steep::VERSION}", tag
402
+ sh "git", "push", "origin", tag
403
+
404
+ puts "🏷️ Pushed #{tag}."
405
+ end
406
+
407
+ desc "Publish the GitHub release for Steep::VERSION, unless it is a `.dev.` version"
408
+ task :gh_release do
409
+ require "open3"
410
+
411
+ version = Gem::Version.new(Steep::VERSION)
412
+ major, minor, *_ = Steep::VERSION.split(".")
413
+ tag = "v#{Steep::VERSION}"
200
414
 
201
- if pre =~ /dev/
202
- puts "🔍 Skipping `Released` tags because `dev` release: `#{pre}`"
415
+ if dev_release?(Steep::VERSION)
416
+ puts "⏭️ #{Steep::VERSION} is a dev release, so there is no GitHub release to publish."
203
417
  next
204
418
  end
205
419
 
206
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
420
+ # The release is created against an existing tag, so that the artifacts and the
421
+ # notes describe a commit that is already immutable.
422
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
423
+ raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?
424
+
425
+ notes = <<~NOTES
426
+ [Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
427
+
428
+ #{changelog_section(Steep::VERSION)}
429
+ NOTES
207
430
 
431
+ # Published rather than drafted: the notes are the changelog section that was
432
+ # already reviewed in the release pull request, so there is nothing left to edit.
208
433
  command = [
209
- "gh",
210
- "pr",
211
- "list",
212
- "--json",
213
- "url,title,number",
214
- "--search" ,
215
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
434
+ "gh", "release", "create", tag,
435
+ "--title=#{Steep::VERSION}",
436
+ "--notes=#{notes}"
216
437
  ]
438
+ command << "--prerelease" if version.prerelease?
217
439
 
218
- require "open3"
219
440
  output, status = Open3.capture2(*command)
220
- raise status.inspect unless status.success?
441
+ raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?
221
442
 
222
- require "json"
223
- json = JSON.parse(output, symbolize_names: true)
224
-
225
- puts " ✅ Found #{json.size} PRs..."
226
-
227
- json.each do |pr|
228
- puts "🧐 Updating #{pr[:url]}..."
229
- output, status = Open3.capture2("gh", "pr", "edit", pr[:number].to_s, "--add-label", "Released")
230
- raise status.inspect unless status.success?
231
- puts " ✅ Done!"
232
- sleep 0.5
233
- end
443
+ puts "📝 Released #{tag}: #{output.chomp}"
234
444
  end
235
445
  end
236
-
237
446
  namespace :rbs do
238
447
  task :watch do
239
448
  require "listen"
data/Steepfile CHANGED
@@ -26,6 +26,8 @@ target :app do
26
26
  signature "sig"
27
27
  ignore_signature "sig/test"
28
28
 
29
+ library "objspace"
30
+
29
31
  implicitly_returns_nil!
30
32
 
31
33
  configure_code_diagnostics(D::Ruby.strict) do |hash|