|
| 1 | +#!/usr/bin/env ruby |
| 2 | + |
| 3 | +require 'steep' |
| 4 | +require 'parser/ruby25' |
| 5 | +require 'json' |
| 6 | + |
| 7 | +METHOD_AND_PARAM_NAME = /(?:\w*|`[^`]+`)/ |
| 8 | +PARAMETER = /(?:\*{1,2})?\s*(?:\??\s*untyped\s*\??\s*|\??#{METHOD_AND_PARAM_NAME}:\s*untyped\s*\??)\s*#{METHOD_AND_PARAM_NAME}/ |
| 9 | +PARAMETERS = /\(\s*(?:\?|(?:(?:#{PARAMETER})\s*(?:,\s*(?:#{PARAMETER})\s*)*)?)\s*\)/ |
| 10 | +PROTOTYPE_INITIALIZE = /\s*(?:public|private)?\s*def\s+initialize:\s*#{PARAMETERS}(?:\s*\??\{\s*#{PARAMETERS}\s*->\s*untyped\s*\})?\s*->\s*void/ |
| 11 | +PROTOTYPE_METHOD = /\s*(?:public|private)?\s*def\s+(?:self\??\.)?(?:[^\s]+):\s*#{PARAMETERS}(?:\s*\??\{\s*#{PARAMETERS}\s*->\s*untyped\s*\})?\s*->\s*untyped/ |
| 12 | + |
| 13 | +# TODO: Find untyped/partially typed attributes, instance variables, class variables, constants |
| 14 | + |
| 15 | +steepfile_path = Pathname(ENV['STEEPFILE_PATH']) |
| 16 | +project = Steep::Project.new(steepfile_path: steepfile_path).tap do |project| |
| 17 | + Steep::Project::DSL.parse(project, steepfile_path.read, filename: steepfile_path.to_s) |
| 18 | +end |
| 19 | +datadog_target = project.targets&.find { |target| target.name == :datadog } |
| 20 | +loader = ::Steep::Services::FileLoader.new(base_dir: project.base_dir) |
| 21 | + |
| 22 | +ignored_paths = datadog_target&.source_pattern&.ignores |
| 23 | + |
| 24 | +# List signature files that are not related to ignored files |
| 25 | +signature_paths_with_ignored_files = loader.each_path_in_patterns(datadog_target.signature_pattern) |
| 26 | +signature_paths = signature_paths_with_ignored_files.reject do |sig_path| |
| 27 | + # replace sig/ with lib/ and .rbs with .rb |
| 28 | + corresponding_lib_file = sig_path.to_s.sub(/^sig/, 'lib').sub(/\.rbs$/, '.rb') |
| 29 | + ignored_paths.any? do |ignored| |
| 30 | + if ignored.end_with?('/') |
| 31 | + # Directory ignore - check if signature file is inside this directory |
| 32 | + corresponding_lib_file.start_with?(ignored) |
| 33 | + else |
| 34 | + # File ignore - check if signature file matches exactly |
| 35 | + corresponding_lib_file == ignored |
| 36 | + end |
| 37 | + end |
| 38 | +end |
| 39 | + |
| 40 | +# Ignored files stats |
| 41 | +ignored_files_size = ignored_paths.inject(0) do |result, path| |
| 42 | + if path.end_with?('/') |
| 43 | + result + Dir.glob(path + '**/*.rb').size |
| 44 | + else |
| 45 | + result + 1 |
| 46 | + end |
| 47 | +end |
| 48 | +total_files_size = Dir.glob("#{project.base_dir}/lib/**/*.rb").size |
| 49 | + |
| 50 | +# steep:ignore comments stats |
| 51 | +ignore_comments = loader.each_path_in_patterns(datadog_target.source_pattern).each_with_object([]) do |path, result| |
| 52 | + buffer = ::Parser::Source::Buffer.new(path.to_s, 1, source: path.read) |
| 53 | + _, comments = ::Parser::Ruby25.new.parse_with_comments(buffer) |
| 54 | + rbs_buffer = ::RBS::Buffer.new(name: path, content: path.read) |
| 55 | + comments.each do |comment| |
| 56 | + ignore = ::Steep::AST::Ignore.parse(comment, rbs_buffer) |
| 57 | + next if ignore.nil? || ignore.is_a?(::Steep::AST::Ignore::IgnoreEnd) |
| 58 | + |
| 59 | + result << { |
| 60 | + path: path.to_s, |
| 61 | + line: ignore.line |
| 62 | + } |
| 63 | + end |
| 64 | +end |
| 65 | + |
| 66 | +# sig files stats |
| 67 | +untyped_methods = [] |
| 68 | +partially_typed_methods = [] |
| 69 | +typed_methods_size = 0 |
| 70 | + |
| 71 | +untyped_others = [] |
| 72 | +partially_typed_others = [] |
| 73 | +typed_others_size = 0 |
| 74 | +signature_paths.each do |sig_path| |
| 75 | + sig_file_content = sig_path.read |
| 76 | + # for each line in the file, check if it matches the regex |
| 77 | + sig_file_content.each_line.with_index(1) do |line, index| |
| 78 | + next if line.strip.empty? || line.strip.start_with?("#") || line.strip.end_with?("# untyped:accept") |
| 79 | + |
| 80 | + case line |
| 81 | + # Methods |
| 82 | + when PROTOTYPE_INITIALIZE |
| 83 | + untyped_methods << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 84 | + when PROTOTYPE_METHOD |
| 85 | + untyped_methods << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 86 | + when /^\s*(?:public|private)?\s*def\s.*untyped/ # Any line containing untyped |
| 87 | + partially_typed_methods << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 88 | + when /^\s*(?:public|private)?\s*def\s.*/ # Any line containing a method definition not matched by the other regexes |
| 89 | + typed_methods_size += 1 |
| 90 | + # Attributes |
| 91 | + when /^\s*(?:public|private)?\s*attr_(?:reader|writer|accessor)\s.*:\s*untyped/ |
| 92 | + untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 93 | + when /^\s*(?:public|private)?\s*attr_(?:reader|writer|accessor)\s.*untyped/ |
| 94 | + partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 95 | + when /^\s*(?:public|private)?\s*attr_(?:reader|writer|accessor)\s.*/ |
| 96 | + typed_others_size += 1 |
| 97 | + # Constants |
| 98 | + when /[A-Z]\w*\s*:\s*untyped/ # We don't match beginning of string as constant can have a namespace prefix |
| 99 | + untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 100 | + when /[A-Z]\w*\s*:[^:].*untyped/ |
| 101 | + partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 102 | + when /[A-Z]\w*\s*:[^:]/ |
| 103 | + typed_others_size += 1 |
| 104 | + # Globals |
| 105 | + when /^\s*\$[a-zA-Z]\w+\s*:\s*untyped/ |
| 106 | + untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 107 | + when /^\s*\$[a-zA-Z]\w+\s*:.*untyped/ |
| 108 | + partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 109 | + when /^\s*\$[a-zA-Z]\w+\s*:/ |
| 110 | + typed_others_size += 1 |
| 111 | + # Class and instance variables |
| 112 | + when /^\s*@?@\w+\s*:\s*untyped/ |
| 113 | + untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 114 | + when /^\s*@?@\w+\s*:.*untyped/ |
| 115 | + partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip} |
| 116 | + when /^\s*@?@\w+\s*:/ |
| 117 | + typed_others_size += 1 |
| 118 | + end |
| 119 | + end |
| 120 | +end |
| 121 | + |
| 122 | +resulting_stats = { |
| 123 | + total_files_size: total_files_size, |
| 124 | + ignored_files: { |
| 125 | + size: ignored_files_size, # Required as we don't list all ignored files, but only their paths |
| 126 | + paths: ignored_paths |
| 127 | + }, |
| 128 | + |
| 129 | + steep_ignore_comments: ignore_comments, |
| 130 | + |
| 131 | + untyped_methods: untyped_methods, |
| 132 | + partially_typed_methods: partially_typed_methods, |
| 133 | + typed_methods_size: typed_methods_size, # Location not needed for already typed methods |
| 134 | + |
| 135 | + untyped_others: untyped_others, |
| 136 | + partially_typed_others: partially_typed_others, |
| 137 | + typed_others_size: typed_others_size # Location not needed for already typed attributes, constants, globals, instance variables |
| 138 | +} |
| 139 | + |
| 140 | +puts resulting_stats.to_json |
0 commit comments