From 3ec979328d7fe5708253fc4b95c7f06a5a7394f5 Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Tue, 24 Feb 2026 21:04:41 +0900 Subject: [PATCH] Add --show-stats option (for experiment) --- lib/typeprof/cli/cli.rb | 2 + lib/typeprof/core/graph/box.rb | 2 +- lib/typeprof/core/service.rb | 224 ++++++++++++++++++++ test/cli_test.rb | 52 +++++ test/fixtures/show_stats/show_stats.rb | 48 +++++ test/fixtures/show_stats/typeprof.conf.json | 5 + 6 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/show_stats/show_stats.rb create mode 100644 test/fixtures/show_stats/typeprof.conf.json diff --git a/lib/typeprof/cli/cli.rb b/lib/typeprof/cli/cli.rb index b2ec1bb5d..da1115a34 100644 --- a/lib/typeprof/cli/cli.rb +++ b/lib/typeprof/cli/cli.rb @@ -38,6 +38,7 @@ def initialize(argv) opt.on("--[no-]show-errors", "Display possible errors found during the analysis") {|v| core_options[:output_diagnostics] = v } opt.on("--[no-]show-parameter-names", "Display parameter names for methods") {|v| core_options[:output_parameter_names] = v } opt.on("--[no-]show-source-locations", "Display definition source locations for methods") {|v| core_options[:output_source_locations] = v } + opt.on("--[no-]show-stats", "Display type inference statistics after analysis (for debugging purpose)") {|v| core_options[:output_stats] = v } opt.separator "" opt.separator "Advanced options:" @@ -67,6 +68,7 @@ def initialize(argv) output_errors: false, output_parameter_names: false, output_source_locations: false, + output_stats: false, exclude_patterns: exclude_patterns, }.merge(core_options) diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index a2c7438aa..3ee7ade66 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -418,7 +418,7 @@ def initialize(node, genv, cpath, singleton, mid, f_args, ret_boxes) attr_accessor :node - attr_reader :cpath, :singleton, :mid, :f_args, :ret + attr_reader :cpath, :singleton, :mid, :f_args, :ret, :record_block def destroy(genv) me = genv.resolve_method(@cpath, @singleton, @mid) diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index 8d0941263..a99aa2409 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -557,6 +557,230 @@ def batch(files, output) end output.puts dump_declarations(file) end + + if @options[:output_stats] + rb_files = show_files.reject {|f| File.extname(f) == ".rbs" } + stats = collect_stats(rb_files) + output.puts + output.puts format_stats(stats) + end + end + + def collect_stats(files) + file_stats = [] + + files.each do |path| + methods = [] + constants = [] + seen_ivars = Set[] + ivars = [] + seen_cvars = Set[] + cvars = [] + seen_gvars = Set[] + gvars = [] + + @rb_text_nodes[path]&.traverse do |event, node| + next unless event == :enter + + node.boxes(:mdef) do |mdef| + param_slots = [] + f = mdef.f_args + [f.req_positionals, f.opt_positionals, f.post_positionals, f.req_keywords, f.opt_keywords].each do |ary| + ary.each {|vtx| param_slots << classify_vertex(vtx) } + end + [f.rest_positionals, f.rest_keywords].each do |vtx| + param_slots << classify_vertex(vtx) if vtx + end + + is_initialize = mdef.mid == :initialize + ret_slots = is_initialize ? [] : [classify_vertex(mdef.ret)] + + blk = mdef.record_block + block_param_slots = [] + block_ret_slots = [] + if blk.used + blk.f_args.each {|vtx| block_param_slots << classify_vertex(vtx) } + block_ret_slots << classify_vertex(blk.ret) + end + + methods << { + mid: mdef.mid, + singleton: mdef.singleton, + param_slots: param_slots, + ret_slots: ret_slots, + block_param_slots: block_param_slots, + block_ret_slots: block_ret_slots, + } + end + + if node.is_a?(AST::ConstantWriteNode) && node.static_cpath + constants << classify_vertex(node.ret) + end + + if node.is_a?(AST::InstanceVariableWriteNode) + scope = node.lenv.cref.scope_level + if scope == :class || scope == :instance + key = [node.lenv.cref.cpath, scope == :class, node.var] + unless seen_ivars.include?(key) + seen_ivars << key + ve = @genv.resolve_ivar(key[0], key[1], key[2]) + ivars << classify_vertex(ve.vtx) + end + end + end + + if node.is_a?(AST::ClassVariableWriteNode) + key = [node.lenv.cref.cpath, node.var] + unless seen_cvars.include?(key) + seen_cvars << key + ve = @genv.resolve_cvar(key[0], key[1]) + cvars << classify_vertex(ve.vtx) + end + end + + if node.is_a?(AST::GlobalVariableWriteNode) + unless seen_gvars.include?(node.var) + seen_gvars << node.var + ve = @genv.resolve_gvar(node.var) + gvars << classify_vertex(ve.vtx) + end + end + end + + file_stats << { + path: path, + methods: methods, + constants: constants, + ivars: ivars, + cvars: cvars, + gvars: gvars, + } + end + + file_stats + end + + def classify_vertex(vtx) + vtx.types.empty? ? :untyped : :typed + end + + def format_stats(stats) + total_methods = 0 + fully_typed = 0 + partially_typed = 0 + fully_untyped = 0 + + slot_categories = %i[param ret blk_param blk_ret const ivar cvar gvar] + typed = Hash.new(0) + untyped = Hash.new(0) + + file_summaries = [] + + stats.each do |file| + f_typed = 0 + f_total = 0 + + file[:methods].each do |m| + total_methods += 1 + + method_slot_keys = %i[param_slots ret_slots block_param_slots block_ret_slots] + category_keys = %i[param ret blk_param blk_ret] + + all_slots = method_slot_keys.flat_map {|k| m[k] } + + method_slot_keys.zip(category_keys) do |slot_key, cat| + m[slot_key].each do |s| + if s == :typed + typed[cat] += 1 + else + untyped[cat] += 1 + end + end + end + + if all_slots.empty? || all_slots.all? {|s| s == :typed } + fully_typed += 1 + elsif all_slots.none? {|s| s == :typed } + fully_untyped += 1 + else + partially_typed += 1 + end + + f_typed += all_slots.count(:typed) + f_total += all_slots.size + end + + %i[constants ivars cvars gvars].zip(%i[const ivar cvar gvar]) do |data_key, cat| + file[data_key].each do |s| + f_total += 1 + if s == :typed + typed[cat] += 1 + f_typed += 1 + else + untyped[cat] += 1 + end + end + end + + if f_total > 0 + file_summaries << { + path: file[:path], + methods: file[:methods].size, + typed: f_typed, + total: f_total, + } + end + end + + overall_typed = slot_categories.sum {|c| typed[c] } + overall_untyped = slot_categories.sum {|c| untyped[c] } + overall_total = overall_typed + overall_untyped + + labels = { + param: "Parameter slots", + ret: "Return slots", + blk_param: "Block parameter slots", + blk_ret: "Block return slots", + const: "Constants", + ivar: "Instance variables", + cvar: "Class variables", + gvar: "Global variables", + } + + lines = [] + lines << "# TypeProf Evaluation Statistics" + lines << "#" + lines << "# Total methods: #{ total_methods }" + lines << "# Fully typed: #{ fully_typed }" + lines << "# Partially typed: #{ partially_typed }" + lines << "# Fully untyped: #{ fully_untyped }" + + slot_categories.each do |cat| + total = typed[cat] + untyped[cat] + lines << "#" + lines << "# #{ labels[cat] }: #{ total }" + lines << "# Typed: #{ typed[cat] } (#{ pct(typed[cat], total) })" + lines << "# Untyped: #{ untyped[cat] } (#{ pct(untyped[cat], total) })" + end + + lines << "#" + lines << "# Overall: #{ overall_typed }/#{ overall_total } typed (#{ pct(overall_typed, overall_total) })" + lines << "# #{ overall_untyped }/#{ overall_total } untyped (#{ pct(overall_untyped, overall_total) })" + + if file_summaries.size > 1 + lines << "#" + lines << "# Per-file breakdown:" + file_summaries.each do |fs| + lines << "# #{ fs[:path] }: #{ fs[:methods] } methods, #{ fs[:typed] }/#{ fs[:total] } typed (#{ pct(fs[:typed], fs[:total]) })" + end + end + + lines.join("\n") + end + + def pct(n, total) + return "0.0%" if total == 0 + "#{ (n * 100.0 / total).round(1) }%" end private diff --git a/test/cli_test.rb b/test/cli_test.rb index 258ab0981..3d14a476a 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -122,6 +122,58 @@ def foo: (String) -> String END end + def test_e2e_show_stats + result = test_run("show_stats", ["--no-show-typeprof-version", "--show-stats", "."]) + stats = result[/# TypeProf Evaluation Statistics.*/m] + assert(stats, "--show-stats should output statistics section") + + # Method summary: 6 methods total + # initialize (no slots) → fully typed + # typed_method (param typed, ret typed) → fully typed + # with_typed_block (ret typed, block_param typed, block_ret typed) → fully typed + # untyped_params (2 params untyped, ret typed) → partially typed + # with_untyped_block (ret untyped, block_param untyped, block_ret untyped) → fully untyped + # uncalled_writer (param untyped, ret untyped) → fully untyped + assert_include(stats, "# Total methods: 6") + assert_include(stats, "# Fully typed: 3") + assert_include(stats, "# Partially typed: 1") + assert_include(stats, "# Fully untyped: 2") + + # Parameter slots: typed_method(1 typed) + untyped_params(2 untyped) + uncalled_writer(1 untyped) + assert_include(stats, "# Parameter slots: 4\n# Typed: 1 (25.0%)\n# Untyped: 3 (75.0%)") + + # Return slots: typed_method(typed) + untyped_params(typed nil) + with_typed_block(typed) + # + with_untyped_block(untyped) + uncalled_writer(untyped) + assert_include(stats, "# Return slots: 5\n# Typed: 3 (60.0%)\n# Untyped: 2 (40.0%)") + + # Block parameter slots: with_typed_block(1 typed) + with_untyped_block(1 untyped) + assert_include(stats, "# Block parameter slots: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)") + + # Block return slots: with_typed_block(1 typed) + with_untyped_block(1 untyped) + assert_include(stats, "# Block return slots: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)") + + # Constants: TYPED_CONST(typed) + Foo::UNTYPED_CONST(untyped) + assert_include(stats, "# Constants: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)") + + # Instance variables: @typed_ivar(typed) + @untyped_ivar(untyped) + assert_include(stats, "# Instance variables: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)") + + # Class variables: @@typed_cvar(typed) + @@untyped_cvar(untyped) + assert_include(stats, "# Class variables: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)") + + # Global variables: $typed_gvar(typed) + $untyped_gvar(untyped) + assert_include(stats, "# Global variables: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)") + + # Overall: 10 typed out of 21 + assert_include(stats, "# Overall: 10/21 typed (47.6%)") + assert_include(stats, "# 11/21 untyped (52.4%)") + end + + def test_e2e_no_show_stats + result = test_run("basic", ["--no-show-typeprof-version", "."]) + assert_not_include(result, "TypeProf Evaluation Statistics") + end + def test_lsp_options_with_lsp_mode assert_nothing_raised { TypeProf::CLI::CLI.new(["--lsp", "--stdio"]) } end diff --git a/test/fixtures/show_stats/show_stats.rb b/test/fixtures/show_stats/show_stats.rb new file mode 100644 index 000000000..a9a78a425 --- /dev/null +++ b/test/fixtures/show_stats/show_stats.rb @@ -0,0 +1,48 @@ +# Typed constant +TYPED_CONST = 1 + +# Typed global variable +$typed_gvar = 99 + +class Foo + # Untyped constant: assigned from unwritten class-level ivar + UNTYPED_CONST = @unset + + # Typed class variable + @@typed_cvar = "hello" + + def initialize + # Typed instance variable + @typed_ivar = 42 + end + + # Fully typed method: param and return both typed + def typed_method(n) + n + end + + # Partially typed method: params untyped (never called), return typed (nil) + def untyped_params(a, b) + end + + # Method with typed block: yields a typed value + def with_typed_block + yield 1 + end + + # Method with untyped block: yields an untyped value (unwritten ivar) + def with_untyped_block + yield @nonexistent + end + + # Never called: param 'a' has no type, so ivar/cvar/gvar assigned from it are untyped + def uncalled_writer(a) + @untyped_ivar = a + @@untyped_cvar = a + $untyped_gvar = a + end +end + +Foo.new.typed_method("str") +Foo.new.with_typed_block {|x| x.to_s } +Foo.new.with_untyped_block {|x| x } diff --git a/test/fixtures/show_stats/typeprof.conf.json b/test/fixtures/show_stats/typeprof.conf.json new file mode 100644 index 000000000..7b146269e --- /dev/null +++ b/test/fixtures/show_stats/typeprof.conf.json @@ -0,0 +1,5 @@ +{ + "typeprof_version": "experimental", + "rbs_dir": "sig/", + "analysis_unit_dirs": ["."] +}