Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions .github/workflows/ci-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,32 @@ jobs:

typecheck:
timeout-minutes: 10
name: typecheck (${{ matrix.format }})
name: typecheck (rbi)
permissions:
contents: read
runs-on: ${{ inputs.runner }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with:
ruby-version: '4.0'
bundler-cache: true
- name: Typecheck rbi files
run: bundle exec rake typecheck:sorbet

validate-rbs:
timeout-minutes: 10
Comment thread
jbeckwith-oai marked this conversation as resolved.
name: validate (rbs)
permissions:
contents: read
runs-on: ${{ inputs.runner }}
env:
# Steep defaults to two workers in CI; eight is the measured balance for this runner.
# The consolidated green path always uses one worker; keep the reference
# fallback tuned so failing builds produce exact diagnostics promptly.
STEEP_JOBS: 8
strategy:
fail-fast: false
matrix:
include:
- format: rbi
task: typecheck:sorbet
- format: rbs
task: typecheck:steep
steps:
Comment thread
jbeckwith-oai marked this conversation as resolved.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
Expand All @@ -100,8 +111,8 @@ jobs:
with:
ruby-version: '4.0'
bundler-cache: true
- name: Typecheck ${{ matrix.format }} files
run: bundle exec rake ${{ matrix.task }}
- name: Validate rbs files
run: bundle exec rake validate:rbs

package:
timeout-minutes: 10
Expand Down Expand Up @@ -151,6 +162,7 @@ jobs:
- stainless-artifact
- lint
- typecheck
- validate-rbs
- package
- test-ruby
if: ${{ always() }}
Expand All @@ -162,11 +174,13 @@ jobs:
ARTIFACT_RESULT: ${{ needs.stainless-artifact.result }}
LINT_RESULT: ${{ needs.lint.result }}
TYPECHECK_RESULT: ${{ needs.typecheck.result }}
RBS_VALIDATION_RESULT: ${{ needs.validate-rbs.result }}
PACKAGE_RESULT: ${{ needs.package.result }}
TEST_RESULT: ${{ needs.test-ruby.result }}
run: |
test "$ARTIFACT_RESULT" = "success" || test "$ARTIFACT_RESULT" = "skipped"
test "$LINT_RESULT" = "success"
test "$TYPECHECK_RESULT" = "success"
test "$RBS_VALIDATION_RESULT" = "success"
test "$PACKAGE_RESULT" = "success"
test "$TEST_RESULT" = "success"
14 changes: 6 additions & 8 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ RuboCop::RakeTask.new(:"lint:rubocop") do |task|
task.patterns = FileList[
"./{lib,test,rbi,examples}/**/*.rb",
"./{lib,test,rbi,examples}/**/*.rbi",
"./scripts/validate-rbs"
]
task.formatters = %w[github] if ENV.key?("CI")

Expand Down Expand Up @@ -129,12 +130,9 @@ end
desc("Format everything")
multitask(format: [:"format:rb", :"format:rbi", :"format:rbs"])

desc("Typecheck `*.rbs`")
multitask(:"typecheck:steep") do
steep = %w[steep check]
steep += ["--jobs", ENV.fetch("STEEP_JOBS")] if ENV.key?("STEEP_JOBS")
steep += %w[--format=github] if ENV.key?("CI")
sh(*steep)
desc("Validate `*.rbs`")
multitask(:"validate:rbs") do
ruby(*%w[scripts/validate-rbs])
end

directory(examples)
Expand All @@ -149,8 +147,8 @@ directory(tapioca) do
sh(*%w[tapioca init])
end

desc("Typecheck everything")
multitask(typecheck: [:"typecheck:steep", :"typecheck:sorbet"])
desc("Typecheck and validate everything")
multitask(typecheck: [:"typecheck:sorbet", :"validate:rbs"])

desc("Lint and typecheck")
multitask(lint: [:"lint:rubocop", :typecheck])
Expand Down
123 changes: 123 additions & 0 deletions scripts/validate-rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "fileutils"
require "open3"
require "pathname"
require "tmpdir"

module OpenAI
module RBSValidation
# These directives change name resolution on a per-file basis, so signatures
# containing them cannot be safely combined and must take the reference path.
FILE_SCOPED_DIRECTIVE = /^\s*(?:use\b|#\s*resolve-type-names\s*:)/

module_function

def run(root: Pathname.pwd, stdout: $stdout, stderr: $stderr, env: ENV.to_h)
steepfile = root.join("Steepfile")
raise Errno::ENOENT, steepfile unless steepfile.file?

signatures = signature_paths(root)
raise "No RBS files found under #{root.join('sig')}" if signatures.empty?

status =
if signatures.any? { requires_reference_check?(_1) }
run_reference_check(root, stdout: stdout, stderr: stderr, env: env)
else
fast_status = run_parse_check(root, env: env)
fast_status = run_consolidated_check(root, steepfile, signatures, env: env) if fast_status.success?
unless fast_status.success?
stdout.puts(
"Fast RBS validation failed; checking original signature files for exact diagnostics."
)
fast_status = run_reference_check(root, stdout: stdout, stderr: stderr, env: env)
end
fast_status
end

if status.success?
noun = signatures.one? ? "file" : "files"
stdout.puts("Validated #{signatures.size} RBS #{noun} with no errors.")
0
else
1
end
rescue StandardError => e
message =
if e.respond_to?(:detailed_message)
e.detailed_message(highlight: false)
else
"#{e.class}: #{e.message}"
end
stderr.puts(message)
1
end

def signature_paths(root)
Dir.glob(root.join("sig/**/*.rbs")).map { Pathname(_1) }
end
private_class_method :signature_paths

def requires_reference_check?(path)
contents = path.binread
# RBS 3.9 treats NUL as EOF. In a combined buffer, that would hide every
# declaration from later files even though the originals parse separately.
# Match the complete file because RBS's magic-comment grammar permits its
# whitespace matcher to span lines (for example, "#\nresolve-type-names:").
contents.include?("\0") || contents.match?(FILE_SCOPED_DIRECTIVE)
end
private_class_method :requires_reference_check?

def run_parse_check(root, env:)
_stdout, _stderr, status = Open3.capture3(env, "rbs", "parse", "sig", chdir: root.to_s)
status
end
private_class_method :run_parse_check

def run_consolidated_check(root, steepfile, signatures, env:)
Dir.mktmpdir("openai-rbs-validation") do |directory|
temporary_root = Pathname(directory)
temporary_sig = temporary_root.join("sig")
temporary_sig.mkpath
temporary_steepfile = temporary_root.join("Steepfile")
FileUtils.cp(steepfile, temporary_steepfile)
# Steep validates reopened namespaces once per input file. Combining the
# generated declarations avoids that repeated work; Steep still performs
# all parsing and semantic validation through its public CLI.
concatenate(signatures, temporary_sig.join("all.rbs"))
Comment thread
jbeckwith-oai marked this conversation as resolved.

command = [env.fetch("STEEP_COMMAND", "steep"), "check", "--no-type-check", "--jobs=1"]
command << "--steepfile=#{temporary_steepfile}"
_stdout, _stderr, status = Open3.capture3(env, *command, chdir: root.to_s)
status
end
end
private_class_method :run_consolidated_check

def concatenate(signatures, destination)
destination.open("wb") do |combined|
signatures.each do |path|
contents = path.binread
combined.write(contents)
Comment thread
jbeckwith-oai marked this conversation as resolved.
combined.write("\n") unless contents.end_with?("\n")
combined.write("\n")
end
end
end
private_class_method :concatenate

def run_reference_check(root, stdout:, stderr:, env:)
command = [env.fetch("STEEP_COMMAND", "steep"), "check", "--no-type-check"]
command << "--jobs=#{env.fetch('STEEP_JOBS')}" if env.key?("STEEP_JOBS")
command << "--format=github" if env.key?("CI")
output, errors, status = Open3.capture3(env, *command, chdir: root.to_s)
stdout.print(output)
stderr.print(errors)
status
end
private_class_method :run_reference_check
end
end

exit(OpenAI::RBSValidation.run) if $PROGRAM_NAME == __FILE__
Loading