TypeProfでRedmineのコードを解析した結果

自分用の備忘録。

最新のTypeProf*1でRedmineのコードを解析した結果を記録しておく。

準備

v6.1.1のコードを取得し、gem_rbs_collectionやrbs_railsのセットアップを実施する。

スクリプトから準備の部分だけ抜粋すると以下のような感じ。

unless File.exist?("config/database.yml")
  File.write("config/database.yml", <<~YAML)
    development:
      adapter: sqlite3
      database: db/development.sqlite3
  YAML
end
gemfile = File.read("Gemfile")
system("bundle add rbs_rails -v '0.13.1'", exception: true) unless gemfile.include?("rbs_rails")
system("bin/rails db:migrate", exception: true) unless File.exist?("db/schema.rb")
system("bundle exec rbs collection init", exception: true) unless File.exist?("rbs_collection.yaml")
system("bundle exec rbs collection install", exception: true) unless File.exist?("rbs_collection.lock.yaml")
system("bundle exec rbs_rails all", exception: true) unless Dir.exist?("sig/rbs_rails")

実行コマンド

app, lib, test, sig を対象に指定して実行。

$ bundle exec typeprof -o redmine_typeprof.out --show-stats app lib test sig

結果

TypeProf の実行時間は 1m4s でした。 --show-stats の出力は以下の通り。

# TypeProf Evaluation Statistics
#
# Total methods: 9764
#   Fully typed:     3826
#   Partially typed: 1070
#   Fully untyped:   4868
#
# Parameter slots: 3617
#   Typed:   2074 (57.3%)
#   Untyped: 1543 (42.7%)
#
# Return slots: 9680
#   Typed:   4367 (45.1%)
#   Untyped: 5313 (54.9%)
#
# Block parameter slots: 110
#   Typed:   78 (70.9%)
#   Untyped: 32 (29.1%)
#
# Block return slots: 108
#   Typed:   48 (44.4%)
#   Untyped: 60 (55.6%)
#
# Constants: 256
#   Typed:   231 (90.2%)
#   Untyped: 25 (9.8%)
#
# Instance variables: 1105
#   Typed:   700 (63.3%)
#   Untyped: 405 (36.7%)
#
# Class variables: 39
#   Typed:   38 (97.4%)
#   Untyped: 1 (2.6%)
#
# Global variables: 5
#   Typed:   5 (100.0%)
#   Untyped: 0 (0.0%)
#
# Overall: 7541/14920 typed (50.5%)
#          7379/14920 untyped (49.5%)

app/ サブディレクトリ別 typed率

サブディレクトリ files methods typed/total slots typed率
app/models/ 85 1,596 2,044 / 3,050 67.0%
app/controllers/ 56 552 587 / 1,071 54.8%
app/helpers/ 41 429 578 / 1,190 48.6%
app/jobs/ 2 9 10 / 24 41.7%
app/validators/ 1 1 1 / 4 25.0%
app/ 合計 185 2,587 3,220 / 5,339 60.3%

untyped の主な要因

  1. actionpackのRBSに untyped が多い
  2. helperは主にview(ERB)から呼ばれるが、 *.erb はTypeProfの解析対象外
  3. gemの提供するメソッドの型定義が存在しない(gem_rbs_collectionに存在しない)
  4. ActiveSupport::Concern, included, send などの動的メソッド

SteepのAnnotationに関する備忘録 #asakusa_bashi_rbs

Steepのmanual/annotations.mdを読みながら、実際にコードを書いて覚えたことをブログにまとめる。

変数

変数の型を String? から String にするときに便利そう。

# @type var value: String
value = %w[a b c].sample
puts('Hi, ' + value)

アノテーションがない場合、 sample: () -> String? なので型検査エラーになる。

app/user.rb:2:14: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::string`
│   (::String | nil) <: ::string
│     (::String | nil) <: (::String | ::_ToStr)
│       nil <: (::String | ::_ToStr)
│         nil <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ puts('Hi, ' + value)
                ~~~~~

Detected 1 problem from 1 file

インスタンス変数

変数と同じく、インスタンス変数の型を変えるときに便利そう。

# ruby
class User
  def initialize(name)
    @name = name
  end

  def hi
    # @type ivar @name: String
    puts('Hi, ' + @name)
  end

  def bye
    puts('bye, ' + @name)
  end
end

# rbs
class User
  @name: String?

  def initialize: (String?) -> void

  def hi: () -> void

  def bye: () -> void
end

これは bye の方だけ型検査エラーになる。

app/user.rb:12:19: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::string`
│   (::String | nil) <: ::string
│     (::String | nil) <: (::String | ::_ToStr)
│       nil <: (::String | ::_ToStr)
│         nil <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└     puts('bye, ' + @name)
                     ~~~~~

Detected 1 problem from 1 file

クラスとメソッド

Rubyのコード内にアノテーションを書くと、RBSなしでもSteepで型チェックできる。

# @type const User : User
class User
  # @type method say: (String, Integer) -> void
  def say(name, age)
    puts(name + age) # 型検査エラー
  end
end


User.new.say(1, 2) # ここは型エラーにならない

このコードをSteepで検査すると、 say メソッドの中だけ型検査の対象になっていることがわかる。

app/user.rb:5:16: [error] Cannot pass a value of type `::Integer` as an argument of type `::string`
│   ::Integer <: ::string
│     ::Integer <: (::String | ::_ToStr)
│       ::Integer <: ::String
│         ::Numeric <: ::String
│           ::Object <: ::String
│             ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└     puts(name + age) # 型検査エラー
                  ~~~

Detected 1 problem from 1 file

pure

メソッドに副作用がないことを明示するときに指定する。*1

# ruby
class User
  def initialize(name)
    @name = name
  end

  def name
    @name
  end
end

user = User.new(nil)
if user.name
  puts("Hi, " + user.name)
end

# rbs
class User
  def initialize: (String?) -> void

  %a{pure}
  def name: () -> String?
end

%a{pure} を指定しない場合、以下のような型検査エラーが起きる。

app/user.rb:13:16: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::string`
│   (::String | nil) <: ::string
│     (::String | nil) <: (::String | ::_ToStr)
│       nil <: (::String | ::_ToStr)
│         nil <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└   puts("Hi, " + user.name)
                  ~~~~~~~~~

Detected 1 problem from 1 file

*1:ドキュメントには記載がない

RBSのuntyped, void, top, botの違いを理解する #asakusa_bashi_rbs

RBSのdocs/syntax.mdに記載されている untyped, void, top, bot の違いを理解したけど、また忘れそうなのでブログにまとめておく。

untyped

どんな型でも代入できて、その変数を型検査の対象にしない場合に使う。

# ruby
class User
  def greet(user)
    user.something
  end
end

# rbs
class User
  def greet: (untyped) -> void
end

something メソッドの定義は存在しないのに、Steepの型検査でエラーは出ない。

No type error detected. 🫖

void

メソッドの戻り値を使わない場合に使う。

# ruby
class User
  def foo
    bar.upcase
  end

  def bar
    "hello"
  end
end

# rbs
class User
  def foo: () -> String
  def bar: () -> void
end

void である戻り値に対して upcase のメソッドを呼び出しているため、Steep の型検査でエラーが出る。

app/user.rb:3:8: [error] Type `void` does not have method `upcase`
│ Diagnostic ID: Ruby::NoMethod
│
└     bar.upcase
          ~~~~~~

top

どんな型でも代入できるが、その変数でメソッド呼び出しを禁止したい場合に使う。TypeScriptのunknown型と同じ。

# ruby
class User
  def greet(user)
    puts user.name
  end
end

user = User.new
user.greet("Pikachu")
user.greet(1)

# rbs
class User
  def greet: (top) -> void
end

型検査で引数に String と Integer を渡している部分ではエラーになっていないが、name のメソッドを呼ぶところはエラーが出る。

app/user.rb:3:14: [error] Type `top` does not have method `name`
│ Diagnostic ID: Ruby::NoMethod
│
└     puts user.name
                ~~~~

bot

例外が必ず発生するか、無限ループで終了しないメソッドで使う。TypeScriptのnever型と同じ。

# ruby
class User
  def foo
    raise NotImplementedError
  end

  def bar
    loop do
      puts "hello"
      sleep 1
    end
  end

  def buz
    puts "hi, Eevee"
  end
end

# rbs
class User
  def foo: () -> bot
  def bar: () -> bot
  def buz: () -> bot
end

Steepで型検査すると foo, bar は問題ないが、 buz は実装が誤っているためエラーが出る。

app/user.rb:13:6: [error] Cannot allow method body have type `nil` because declared as type `bot`
│   nil <: bot
│
│ Diagnostic ID: Ruby::MethodBodyTypeMismatch
│
└   def buz
        ~~~

RubyVM::AbstractSyntaxTree で Node を辿る処理をシンプルに実装する

Ruby の Enumerator を使うと簡単にRubyのコードを処理できて便利だったので、ブログに書いておく。

ast = RubyVM::AbstractSyntaxTree.parse(<<~RUBY)
  class User < ApplicationRecord
    def say(text)
      puts text
    end

    def sum(x, y)
      x + y
    end
  end
RUBY

enum = Enumerator.new { |y|
  nodes = ast.children
  until nodes.empty?
    nodes.select! { _1.is_a?(RubyVM::AbstractSyntaxTree::Node) }
    nodes.each { y << _1 }
    nodes = nodes.flat_map(&:children)
  end
}

method_names = enum.select { _1.type == :DEFN }.map { _1.children[0] }
pp method_names
#=> [:say, :sum]

【追記】Rails v7.1.0 で `can't be blank` が `can’t be blank` に変わる(リバートされました)

既存アプリやライブラリへの影響が大きく、この変更に対してネガティブなフィードバックも多かったためリバートされました。 github.com


概要

表題の通り、Rails v7.1.0 で APOSTROPHE (U+0027) が SINGLE QUOTATION MARK (U+2019) に変わります。

github.com

既存のRailsアプリをアップグレードする際に影響が大きそうなので、記事を書きました。

影響範囲

テストでエラーメッセージを検証していた場合、Rails v7.1.0 のアップグレードによって検証に失敗するようになります。

Expected: "can't be blank"
  Actual: "can’t be blank"

今回の変更を知らない場合、このテストのエラーメッセージだけで ' と ’ の違いを見分けるのは厳しそう。

SINGLE QUOTATION MARK (U+2019) の入力方法

日本語入力モードで「Shift + 7」を押すと入力できました。*1

変更が戻る可能性

マージされるときも「I'll merge this one and see how people will react to them during the pre-releases.」と書いてあり、マージ後にrails/rails#45463でいくつかコメントが挙がっているので、もしかすると変更がリバートされる可能性はあります。

気になる方やこの変更に対して懸念がある方は v7.1.0.rc が出る前にGitHubで絵文字リアクションやコメントを投稿しておくと良さそうです。

*1:私はJIS配列キーボード使ってるので、英字キーボードだと入力方法が異なるかもしれない

プルリクで更新されたgemの一覧を出力する

Gemfile.lock のdiffから更新されたgemを探す方法を調べたのでメモ

動作確認に使ったプルリク

github.com

GitHub APIでプルリクの情報を取得する

$ gh api '/repos/rails/rails/pulls/48955' --jq '.base.sha, .head.sha'
1e824aa8e0655b4717cf612e31010bdd808f4fdf
2d61a206f445ccd52645c8d47ce97de39a39ea9d

GitHub APIで変更前後のGemfileを取得する

$ gh api '/repos/rails/rails/contents/Gemfile.lock?ref=1e824aa8e0655b4717cf612e31010bdd808f4fdf' --jq '.content' | base64 --decode > Gemfile.lock.before
$ gh api '/repos/rails/rails/contents/Gemfile.lock?ref=2d61a206f445ccd52645c8d47ce97de39a39ea9d' --jq '.content' | base64 --decode > Gemfile.lock.after

Gemfile.lock の差分を調べる

$ BUNDLE_GEMFILE=Gemfile.lock.before ruby -rbundler -e \
  'puts ["Gemfile.lock.after", "Gemfile.lock.before"].map { Bundler::LockfileParser.new(File.read(_1)).specs }.inject(:-)'
nio4r (2.5.9)
puma (6.3.0)

Gemfileがないディレクトリだと Could not locate Gemfile or .bundle/ directory のエラーが出たので、 BUNDLE_GEMFILE を指定することでエラーを回避している。

Rubyやgit-gsubを使って正規表現で複数行を置換する part2

sinsoku.hatenablog.com

の続き。

回答 3

Rubyで -0777 を指定するとファイル単位で読み込む方法を教えて頂きました。

$ cat foo.txt
foo

$ cat bar.txt
bar

$ cat buz.txt
foo
bar
buz

$ ruby -i -n0777 -e 'puts "hi, " + $_' foo.txt bar.txt buz.txt

$ cat foo.txt
hi, foo

$ cat bar.txt
hi, bar

$ cat buz.txt
hi, foo
bar
buz

詳細

-0777 のオプションは初めて知ったけど、 man ruby に記載されていました。

-0[octal]. (The digit “zero”.) Specifies the input record separator ($/) as an octal number. If no digit is given, the null character is taken as the separator. Other switches may follow the digits. -00 turns Ruby into paragraph mode. -0777 makes Ruby read whole file at once as a single string since there is no legal character with that value.

まとめ

複数のファイルを一括で編集するときに -0777 は覚えておくと便利そう。