steep 1.10.0 → 2.1.0.dev.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/CLAUDE.md +114 -0
  4. data/README.md +2 -2
  5. data/Rakefile +365 -150
  6. data/Steepfile +13 -13
  7. data/doc/release.md +264 -0
  8. data/lib/steep/annotation_parser.rb +5 -1
  9. data/lib/steep/annotations_helper.rb +12 -2
  10. data/lib/steep/ast/node/type_application.rb +22 -16
  11. data/lib/steep/ast/node/type_assertion.rb +7 -4
  12. data/lib/steep/ast/types/factory.rb +3 -2
  13. data/lib/steep/cli.rb +246 -2
  14. data/lib/steep/daemon/configuration.rb +19 -0
  15. data/lib/steep/daemon/server.rb +476 -0
  16. data/lib/steep/daemon.rb +201 -0
  17. data/lib/steep/diagnostic/ruby.rb +54 -12
  18. data/lib/steep/diagnostic/signature.rb +31 -8
  19. data/lib/steep/drivers/check.rb +301 -140
  20. data/lib/steep/drivers/print_project.rb +9 -10
  21. data/lib/steep/drivers/query.rb +102 -0
  22. data/lib/steep/drivers/start_server.rb +19 -0
  23. data/lib/steep/drivers/stop_server.rb +20 -0
  24. data/lib/steep/drivers/watch.rb +2 -2
  25. data/lib/steep/index/rbs_index.rb +38 -13
  26. data/lib/steep/index/signature_symbol_provider.rb +24 -3
  27. data/lib/steep/interface/builder.rb +48 -15
  28. data/lib/steep/interface/shape.rb +13 -5
  29. data/lib/steep/locator.rb +377 -0
  30. data/lib/steep/node_helper.rb +2 -2
  31. data/lib/steep/project/dsl.rb +26 -5
  32. data/lib/steep/project/group.rb +8 -2
  33. data/lib/steep/project/target.rb +16 -2
  34. data/lib/steep/project.rb +21 -2
  35. data/lib/steep/server/base_worker.rb +2 -2
  36. data/lib/steep/server/change_buffer.rb +2 -1
  37. data/lib/steep/server/custom_methods.rb +12 -0
  38. data/lib/steep/server/inline_source_change_detector.rb +94 -0
  39. data/lib/steep/server/interaction_worker.rb +51 -74
  40. data/lib/steep/server/lsp_formatter.rb +48 -12
  41. data/lib/steep/server/master.rb +100 -18
  42. data/lib/steep/server/target_group_files.rb +124 -151
  43. data/lib/steep/server/type_check_controller.rb +276 -123
  44. data/lib/steep/server/type_check_worker.rb +104 -3
  45. data/lib/steep/services/completion_provider/rbs.rb +74 -0
  46. data/lib/steep/services/completion_provider/ruby.rb +652 -0
  47. data/lib/steep/services/completion_provider/type_name.rb +243 -0
  48. data/lib/steep/services/completion_provider.rb +39 -662
  49. data/lib/steep/services/content_change.rb +14 -1
  50. data/lib/steep/services/file_loader.rb +4 -2
  51. data/lib/steep/services/goto_service.rb +271 -68
  52. data/lib/steep/services/hover_provider/content.rb +67 -0
  53. data/lib/steep/services/hover_provider/rbs.rb +8 -9
  54. data/lib/steep/services/hover_provider/ruby.rb +124 -65
  55. data/lib/steep/services/hover_provider/singleton_methods.rb +4 -0
  56. data/lib/steep/services/signature_help_provider.rb +1 -1
  57. data/lib/steep/services/signature_service.rb +129 -54
  58. data/lib/steep/services/type_check_service.rb +72 -27
  59. data/lib/steep/signature/validator.rb +30 -18
  60. data/lib/steep/source/ignore_ranges.rb +14 -4
  61. data/lib/steep/source.rb +21 -7
  62. data/lib/steep/tagged_logging.rb +39 -0
  63. data/lib/steep/type_construction.rb +111 -24
  64. data/lib/steep/type_inference/block_params.rb +7 -7
  65. data/lib/steep/type_inference/context.rb +4 -2
  66. data/lib/steep/type_inference/logic_type_interpreter.rb +24 -3
  67. data/lib/steep/type_inference/method_call.rb +4 -0
  68. data/lib/steep/type_inference/type_env.rb +1 -1
  69. data/lib/steep/typing.rb +3 -5
  70. data/lib/steep/version.rb +1 -1
  71. data/lib/steep.rb +42 -32
  72. data/manual/ruby-diagnostics.md +67 -0
  73. data/sample/Steepfile +1 -0
  74. data/sample/lib/conference.rb +1 -0
  75. data/sample/lib/deprecated.rb +6 -0
  76. data/sample/lib/inline.rb +43 -0
  77. data/sample/sig/generics.rbs +3 -0
  78. data/steep.gemspec +4 -5
  79. metadata +27 -26
  80. data/lib/steep/services/type_name_completion.rb +0 -236
data/Rakefile CHANGED
@@ -19,212 +19,423 @@ namespace :test do
19
19
  task :output do
20
20
  sh "ruby", "bin/output_test.rb"
21
21
  end
22
- end
23
22
 
24
- Rake::Task[:release].enhance do
25
- Rake::Task[:"release:note"].invoke
26
- Rake::Task[:"release:github"].invoke
27
- Rake::Task[:"release:release-prs"].invoke
23
+ namespace :output do
24
+ desc "Run current output test"
25
+ task :current do
26
+ puts ">> Running `steep check` in #{ENV["PWD"]}"
27
+ sh "steep", "check", "--with-expectations=test_expectations.yml", chdir: ENV["PWD"]
28
+ end
29
+ end
28
30
  end
29
31
 
30
- desc "Generate changelog template from GH pull requests"
31
- task :changelog do
32
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
33
- major = major.to_i
34
- minor = minor.to_i
35
- patch = patch.to_i
32
+ # Pull requests with one of these labels are omitted from the changelog.
33
+ CHANGELOG_SKIP_LABELS = ["skip-changelog"]
34
+
35
+ # The tags a release proper starts *after*, rather than at.
36
+ CHANGELOG_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"]
37
+
38
+ # Resolves the commit-ish the changelog of the release being prepared starts from.
39
+ #
40
+ # `version` is a version number, a tag, or any commit-ish. When it is omitted, the base follows
41
+ # `Steep::VERSION`:
42
+ #
43
+ # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag.
44
+ # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags
45
+ # in between and starts from the previous release proper.
46
+ #
47
+ # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a
48
+ # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version
49
+ # explicitly overrides all of it.
50
+ #
51
+ def changelog_base(version)
52
+ require "open3"
36
53
 
37
- if patch == 0
38
- milestone = "Steep #{major}.#{minor}"
39
- else
40
- milestone = "Steep #{major}.#{minor}.x"
41
- end
54
+ from =
55
+ if version
56
+ # `2.1.0` and `v2.1.0` both mean the tag `v2.1.0`, while `master` or a SHA is used as is.
57
+ version.match?(/\A\d/) ? "v#{version}" : version
58
+ else
59
+ command = ["git", "describe", "--tags", "--match", "v*", "--abbrev=0"]
60
+ unless Gem::Version.new(Steep::VERSION).prerelease?
61
+ CHANGELOG_PRERELEASE_TAGS.each { |glob| command.push("--exclude", glob) }
62
+ end
42
63
 
43
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
64
+ output, status = Open3.capture2(*command)
65
+ raise "🚨 Cannot detect the tag the changelog starts from. Give the previous version explicitly." unless status.success?
66
+ output.chomp
67
+ end
44
68
 
45
- command = [
46
- "gh",
47
- "pr",
48
- "list",
49
- "--json",
50
- "url,title,number",
51
- "--limit=100",
52
- "--search" ,
53
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
54
- ]
69
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}")
70
+ raise "🚨 No such commit-ish: `#{from}`" unless status.success?
55
71
 
56
- require "open3"
57
- output, status = Open3.capture2(*command)
58
- raise status.inspect unless status.success?
72
+ from
73
+ end
59
74
 
75
+ # Runs a GraphQL query against the repository of the working directory.
76
+ #
77
+ # `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name`
78
+ # variables. Returns the contents of `data.repository`.
79
+ #
80
+ def changelog_graphql(body)
81
+ require "open3"
60
82
  require "json"
61
- json = JSON.parse(output, symbolize_names: true)
62
83
 
63
- unless json.empty?
64
- puts
65
- json.each do |line|
66
- puts "* #{line[:title]} ([##{line[:number]}](#{line[:url]}))"
84
+ @changelog_repository ||=
85
+ begin
86
+ output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
87
+ raise status.inspect unless status.success?
88
+ output.chomp.split("/", 2)
67
89
  end
68
- else
69
- puts " (🤑 There is no *unreleased* pull request associated to the milestone.)"
70
- end
90
+ owner, name = @changelog_repository
91
+
92
+ query = <<~GRAPHQL
93
+ query($owner: String!, $name: String!) {
94
+ repository(owner: $owner, name: $name) {
95
+ #{body}
96
+ }
97
+ }
98
+ GRAPHQL
99
+
100
+ output, status = Open3.capture2(
101
+ "gh", "api", "graphql",
102
+ "-f", "query=#{query}",
103
+ "-f", "owner=#{owner}",
104
+ "-f", "name=#{name}",
105
+ binmode: true
106
+ )
107
+ raise status.inspect unless status.success?
108
+
109
+ # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without
110
+ # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions.
111
+ JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
71
112
  end
72
113
 
73
- namespace :release do
74
- desc "Ensure release note"
75
- task :note do
76
- version = Gem::Version.new(Steep::VERSION)
77
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
78
- major = major.to_i
79
- minor = minor.to_i
80
- patch = patch.to_i
114
+ # Lists the commits between `from` and `HEAD`, newest first.
115
+ #
116
+ def changelog_commits(from)
117
+ require "open3"
81
118
 
82
- wiki_url = "https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor}"
119
+ output, status = Open3.capture2("git", "log", "--format=%H", "#{from}..HEAD")
120
+ raise status.inspect unless status.success?
83
121
 
84
- puts "🧐 Checking if Release note page already exists..."
122
+ output.lines.map(&:chomp).reject(&:empty?)
123
+ end
85
124
 
86
- unless `curl --silent -o/dev/null --head #{wiki_url} -w '%{http_code}'` == "200"
87
- pre_flag = version.prerelease? ? " --pre" : ""
88
- pre_requirement = version.prerelease? ? ", '~> #{major}.#{minor}.#{patch}.#{pre}'" : ""
125
+ # What `git cherry-pick -x` appends to the message of the commit it creates.
126
+ CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/
127
+
128
+ # Maps the commits that record where they were cherry-picked from to that commit.
129
+ #
130
+ # A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit
131
+ # itself, that leads to the pull request the change was written and reviewed in. Without this a
132
+ # backported change is attributed to the pull request that carried the backport, which says
133
+ # nothing about the change and is the same for every commit it brought over.
134
+ #
135
+ # A commit backported twice -- the development line, then a release branch -- carries one line
136
+ # per hop, appended in order, so the first one is where the change started.
137
+ #
138
+ def changelog_origins(commits)
139
+ return {} if commits.empty?
89
140
 
90
- puts "----"
141
+ require "open3"
91
142
 
92
- puts <<~PREFIX if patch
93
- **The latest version of Steep #{major}.#{minor} is `#{pre}`.**
143
+ # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message --
144
+ # which can contain anything, including what this format looks like -- from being read as the
145
+ # format itself.
146
+ output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true)
147
+ raise status.inspect unless status.success?
94
148
 
95
- PREFIX
149
+ # Commit messages are UTF-8, while the default external encoding follows the locale. Without
150
+ # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions.
151
+ output.force_encoding(Encoding::UTF_8)
96
152
 
97
- puts <<~TEMPLATE
98
- Some of the highlights in Steep #{major}.#{minor} are:
153
+ output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins|
154
+ commit = commit.to_s.strip
155
+ next if commit.empty?
99
156
 
100
- * New feature 1 (URL)
101
- * New feature 2 (URL)
102
- * New feature 3 (URL)
157
+ origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next
158
+ origins[commit] = origin
159
+ end
160
+ end
103
161
 
104
- You can install it with `$ gem install steep#{pre_flag}` or using Bundler.
162
+ # Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge
163
+ # commit, squash, or rebase -- is handled without parsing commit messages.
164
+ #
165
+ # Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no
166
+ # merged pull request for, including the ones it does not know at all.
167
+ #
168
+ def changelog_associated_pull_requests(oids)
169
+ oids.uniq.each_slice(50).each_with_object({}) do |slice, found|
170
+ aliases = slice.map.with_index do |oid, index|
171
+ <<~GRAPHQL
172
+ c#{index}: object(oid: "#{oid}") {
173
+ ... on Commit {
174
+ associatedPullRequests(first: 10) {
175
+ nodes {
176
+ number title url merged
177
+ labels(first: 100) { nodes { name } }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ GRAPHQL
183
+ end
105
184
 
106
- ```rb
107
- gem 'steep', require: false#{pre_requirement}
108
- ```
185
+ response = changelog_graphql(aliases.join("\n"))
109
186
 
110
- See the [CHANGELOG](https://github.com/soutaro/steep/blob/master/CHANGELOG.md) for the details.
187
+ slice.each_with_index do |oid, index|
188
+ nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || []
111
189
 
112
- ## New feature 1
190
+ found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr|
191
+ { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } }
192
+ end
193
+ end
194
+ end
195
+ end
113
196
 
114
- ## New feature 2
197
+ # Finds the pull requests the commits came from, keeping the order of `commits`.
198
+ #
199
+ # Returns the pull requests for the changelog and the ones omitted by `skip_labels`.
200
+ #
201
+ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
202
+ origins = changelog_origins(commits)
203
+ found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit })
204
+
205
+ # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
206
+ # default branch without a pull request -- falls back to the commit in this history, which is
207
+ # at least the backport that brought it here.
208
+ fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? }
209
+ found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty?
210
+
211
+ pull_requests = {}
212
+ skipped = {}
213
+
214
+ commits.each do |commit|
215
+ prs = found.fetch(origins[commit] || commit, [])
216
+ prs = found.fetch(commit, []) if prs.empty?
217
+
218
+ prs.each do |pr|
219
+ if (pr[:labels] & skip_labels).empty?
220
+ pull_requests[pr[:number]] ||= pr
221
+ else
222
+ skipped[pr[:number]] ||= pr
223
+ end
224
+ end
225
+ end
115
226
 
116
- ## New feature 3
227
+ [pull_requests.values, skipped.values]
228
+ end
117
229
 
118
- ## Diagnostics updates
230
+ # Fetches the details that help classifying the pull requests: the changed files and the body.
231
+ #
232
+ def changelog_pull_request_details(pull_requests)
233
+ pull_requests.each_slice(50).flat_map do |slice|
234
+ aliases = slice.map do |pr|
235
+ <<~GRAPHQL
236
+ p#{pr[:number]}: pullRequest(number: #{pr[:number]}) {
237
+ body
238
+ author { login }
239
+ files(first: 100) {
240
+ nodes { path }
241
+ pageInfo { hasNextPage }
242
+ }
243
+ }
244
+ GRAPHQL
245
+ end
119
246
 
120
- ## Updating Steep
247
+ details = changelog_graphql(aliases.join("\n"))
121
248
 
122
- TEMPLATE
123
- puts "----"
124
- puts
249
+ slice.map do |pr|
250
+ detail = details[:"p#{pr[:number]}"] or next pr
125
251
 
126
- puts " ⏩️ Create the release note with the template: #{wiki_url}"
127
- else
128
- if patch == 0 || version.prerelease?
129
- puts " ⏩️ Open the release note and update it at: #{wiki_url}"
130
- else
131
- puts " ✅ Release note is ready!"
132
- end
252
+ pr.merge(
253
+ author: detail.dig(:author, :login),
254
+ # The body is a hint for writing the changelog, not a copy source. Keep it short.
255
+ body: detail[:body].to_s.strip.slice(0, 1000),
256
+ files: detail.dig(:files, :nodes).map { |file| file[:path] },
257
+ files_truncated: detail.dig(:files, :pageInfo, :hasNextPage)
258
+ )
133
259
  end
134
- puts
135
260
  end
261
+ end
136
262
 
137
- desc "Create GitHub release automatically"
138
- task :github do
139
- version = Gem::Version.new(Steep::VERSION)
140
- major, minor, patch, *_ = Steep::VERSION.split(".")
141
- major = major.to_i
142
- minor = minor.to_i
143
- patch = patch.to_i
144
-
145
- puts "✏️ Making a draft release on GitHub..."
263
+ # Reports the pull requests omitted by their label, so that they do not disappear silently.
264
+ #
265
+ def warn_skipped_pull_requests(skipped, skip_labels)
266
+ return if skipped.empty?
146
267
 
147
- content = File.read(File.join(__dir__, "CHANGELOG.md"))
148
- changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0]
149
- changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
268
+ numbers = skipped.map { |pr| "##{pr[:number]}" }
269
+ numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20
150
270
 
151
- notes = <<NOTES
152
- [Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
271
+ $stderr.puts
272
+ $stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
273
+ end
153
274
 
154
- #{changelog}
155
- NOTES
275
+ # Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
276
+ #
277
+ # The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
278
+ # piped to another command: `rake gem:changelog | pbcopy`
279
+ #
280
+ def print_changelog(from, skip_labels: CHANGELOG_SKIP_LABELS)
281
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
282
+
283
+ commits = changelog_commits(from)
284
+ if commits.empty?
285
+ $stderr.puts " (🤔 There is no commit after `#{from}`.)"
286
+ return
287
+ end
156
288
 
157
- command = [
158
- "gh",
159
- "release",
160
- "create",
161
- "--draft",
162
- "v#{Steep::VERSION}",
163
- "--title=#{Steep::VERSION}",
164
- "--notes=#{notes}"
165
- ]
289
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
166
290
 
167
- if version.prerelease?
168
- command << "--prerelease"
291
+ if pull_requests.empty?
292
+ $stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)"
293
+ else
294
+ $stderr.puts
295
+ pull_requests.each do |pr|
296
+ puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))"
169
297
  end
298
+ $stdout.flush
299
+ end
170
300
 
171
- require "open3"
172
- output, status = Open3.capture2(*command)
173
- if status.success?
174
- puts " ⏩️ Done! Open #{output.chomp} and publish the release!"
175
- puts
301
+ warn_skipped_pull_requests(skipped, skip_labels)
302
+ end
303
+
304
+ # Prints the same pull requests as `print_changelog` as JSON, with the details that help
305
+ # classifying them into the sections of CHANGELOG.md.
306
+ #
307
+ # This is the input for the release automation, so it always prints a valid JSON document.
308
+ #
309
+ def print_changelog_json(from, skip_labels: CHANGELOG_SKIP_LABELS)
310
+ require "json"
311
+
312
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
313
+
314
+ commits = changelog_commits(from)
315
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
316
+ pull_requests = changelog_pull_request_details(pull_requests)
317
+
318
+ $stderr.puts " (📋 #{pull_requests.size} pull request(s))"
319
+
320
+ puts JSON.pretty_generate(
321
+ {
322
+ from: from,
323
+ to: "HEAD",
324
+ pull_requests: pull_requests,
325
+ skipped: skipped
326
+ }
327
+ )
328
+ $stdout.flush
329
+
330
+ warn_skipped_pull_requests(skipped, skip_labels)
331
+ end
332
+
333
+ namespace :gem do
334
+ desc "Generate changelog template from GH pull requests merged since the previous release"
335
+ task :changelog, [:version] do |_task, args|
336
+ print_changelog(changelog_base(args[:version]))
337
+ end
338
+
339
+ namespace :changelog do
340
+ desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each"
341
+ task :json, [:version] do |_task, args|
342
+ print_changelog_json(changelog_base(args[:version]))
176
343
  end
177
344
  end
178
345
 
179
- desc "Add `Released` labels to pull requests associated to the version"
180
- task "release-prs" do
181
- major, minor, patch, pre = Steep::VERSION.split(".", 4)
182
- major = major.to_i
183
- minor = minor.to_i
184
- patch = patch.to_i
346
+ # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The
347
+ # `.dev.N` ones are cut from the development line for people who need a specific
348
+ # change early; they are not written up in the changelog, so there are no notes to
349
+ # publish and nothing worth announcing.
350
+ def dev_release?(version)
351
+ Gem::Version.new(version).segments.include?("dev")
352
+ end
353
+
354
+ # The body of the topmost section of CHANGELOG.md, which is the release being
355
+ # prepared, minus its own heading.
356
+ #
357
+ # The encoding is explicit because the default external encoding follows the
358
+ # locale, and the changelog is not ASCII.
359
+ #
360
+ def changelog_section(version)
361
+ content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
362
+ section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
363
+ heading, _, body = section.partition("\n")
364
+ heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}"
365
+ body.strip
366
+ end
367
+
368
+ desc "Check that the working tree is ready to be released as the given version"
369
+ task :check_release, [:version] do |_task, args|
370
+ version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[2.1.0]'`"
371
+ Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number."
372
+
373
+ # The version being released and the version the commit declares are stated
374
+ # separately -- one by whoever starts the release, one by the commit itself --
375
+ # so that releasing the wrong commit, or releasing the right one under the wrong
376
+ # name, fails here rather than on RubyGems.
377
+ version == Steep::VERSION or
378
+ raise "🚨 Releasing #{version}, but this commit declares `Steep::VERSION = #{Steep::VERSION.inspect}`."
185
379
 
186
- if patch == 0
187
- milestone = "Steep #{major}.#{minor}"
380
+ if dev_release?(version)
381
+ puts " #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked."
188
382
  else
189
- milestone = "Steep #{major}.#{minor}.x"
383
+ changelog_section(version)
384
+ puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it."
190
385
  end
386
+ end
387
+
388
+ desc "Create and push the `vX.Y.Z` tag for Steep::VERSION"
389
+ task :tag do
390
+ tag = "v#{Steep::VERSION}"
391
+
392
+ # Annotated, so that the tag carries its own author and date rather than
393
+ # borrowing the tagged commit's.
394
+ sh "git", "tag", "--annotate", "--message", "Steep #{Steep::VERSION}", tag
395
+ sh "git", "push", "origin", tag
396
+
397
+ puts "🏷️ Pushed #{tag}."
398
+ end
399
+
400
+ desc "Publish the GitHub release for Steep::VERSION, unless it is a `.dev.` version"
401
+ task :gh_release do
402
+ require "open3"
191
403
 
192
- if pre =~ /dev/
193
- puts "🔍 Skipping `Released` tags because `dev` release: `#{pre}`"
404
+ version = Gem::Version.new(Steep::VERSION)
405
+ major, minor, *_ = Steep::VERSION.split(".")
406
+ tag = "v#{Steep::VERSION}"
407
+
408
+ if dev_release?(Steep::VERSION)
409
+ puts "⏭️ #{Steep::VERSION} is a dev release, so there is no GitHub release to publish."
194
410
  next
195
411
  end
196
412
 
197
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
413
+ # The release is created against an existing tag, so that the artifacts and the
414
+ # notes describe a commit that is already immutable.
415
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
416
+ raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?
417
+
418
+ notes = <<~NOTES
419
+ [Release note](https://github.com/soutaro/steep/wiki/Release-Note-#{major}.#{minor})
198
420
 
421
+ #{changelog_section(Steep::VERSION)}
422
+ NOTES
423
+
424
+ # Published rather than drafted: the notes are the changelog section that was
425
+ # already reviewed in the release pull request, so there is nothing left to edit.
199
426
  command = [
200
- "gh",
201
- "pr",
202
- "list",
203
- "--json",
204
- "url,title,number",
205
- "--search" ,
206
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
427
+ "gh", "release", "create", tag,
428
+ "--title=#{Steep::VERSION}",
429
+ "--notes=#{notes}"
207
430
  ]
431
+ command << "--prerelease" if version.prerelease?
208
432
 
209
- require "open3"
210
433
  output, status = Open3.capture2(*command)
211
- raise status.inspect unless status.success?
212
-
213
- require "json"
214
- json = JSON.parse(output, symbolize_names: true)
215
-
216
- puts " ✅ Found #{json.size} PRs..."
434
+ raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?
217
435
 
218
- json.each do |pr|
219
- puts "🧐 Updating #{pr[:url]}..."
220
- output, status = Open3.capture2("gh", "pr", "edit", pr[:number].to_s, "--add-label", "Released")
221
- raise status.inspect unless status.success?
222
- puts " ✅ Done!"
223
- sleep 0.5
224
- end
436
+ puts "📝 Released #{tag}: #{output.chomp}"
225
437
  end
226
438
  end
227
-
228
439
  namespace :rbs do
229
440
  task :watch do
230
441
  require "listen"
@@ -232,7 +443,9 @@ namespace :rbs do
232
443
  paths = (modified + added).map do
233
444
  Pathname(_1).relative_path_from(Pathname.pwd)
234
445
  end
235
- sh "rbs-inline", "--opt-out", "--output=sig", *paths.map(&:to_s)
446
+ Bundler.with_unbundled_env do
447
+ sh "bin/rbs-inline", "--opt-out", "--output=sig", *paths.map(&:to_s)
448
+ end
236
449
  end
237
450
  listener.start
238
451
  begin
@@ -243,7 +456,9 @@ namespace :rbs do
243
456
  end
244
457
 
245
458
  task :generate do
246
- sh "rbs-inline --opt-out --output=sig test"
247
- sh "rbs-inline --opt-out --output=tmp/rbs-inline bin/generate-diagnostics-docs.rb"
459
+ Bundler.with_unbundled_env do
460
+ sh "bin/rbs-inline --opt-out --output=sig test"
461
+ sh "bin/rbs-inline --opt-out --output=tmp/rbs-inline bin/generate-diagnostics-docs.rb"
462
+ end
248
463
  end
249
464
  end
data/Steepfile CHANGED
@@ -5,14 +5,16 @@ D = Steep::Diagnostic
5
5
  FileUtils.mkpath("tmp")
6
6
  tmp_rbs_dir = Pathname("tmp/rbs-sig")
7
7
 
8
- definition = Bundler::Definition.build(Pathname("Gemfile"), Pathname("Gemfile.lock"), nil)
9
- rbs_dep = definition.dependencies.find {|dep| dep.name == "rbs" }
10
- if (source = rbs_dep&.source).is_a?(Bundler::Source::Path)
11
- unless tmp_rbs_dir.directory?
12
- FileUtils.ln_s(Pathname.pwd + source.path + "sig", tmp_rbs_dir.to_s, force: true)
8
+ # The DSL is executed in the main process and worker processes, and the file operations introduce a race condition.
9
+ # This line ensures the operations are executed only in the main process.
10
+ if caller.none? {|c| c =~ /\/worker_process\.rb:/ }
11
+ tmp_rbs_dir.delete if tmp_rbs_dir.exist?
12
+
13
+ definition = Bundler::Definition.build(Pathname("Gemfile"), Pathname("Gemfile.lock"), nil)
14
+ rbs_dep = definition.dependencies.find {|dep| dep.name == "rbs" }
15
+ if (source = rbs_dep&.source).is_a?(Bundler::Source::Path)
16
+ tmp_rbs_dir.make_symlink(Pathname.pwd + source.path + "sig")
13
17
  end
14
- else
15
- FileUtils.rm_f(tmp_rbs_dir)
16
18
  end
17
19
 
18
20
  target :app do
@@ -47,9 +49,7 @@ target :test do
47
49
 
48
50
  configure_code_diagnostics(D::Ruby.lenient)
49
51
 
50
- if tmp_rbs_dir.directory?
51
- signature tmp_rbs_dir.to_s
52
- else
52
+ unless tmp_rbs_dir.directory?
53
53
  library "rbs"
54
54
  end
55
55
  end
@@ -63,9 +63,9 @@ target :bin do
63
63
  check "bin/generate-diagnostics-docs.rb"
64
64
  signature "tmp/rbs-inline/bin"
65
65
 
66
- if tmp_rbs_dir.directory?
67
- signature tmp_rbs_dir.to_s
68
- else
66
+ configure_code_diagnostics(D::Ruby.lenient)
67
+
68
+ unless tmp_rbs_dir.directory?
69
69
  library "rbs"
70
70
  end
71
71
  end