diff --git a/.github/scripts/typing_stats_compare.rb b/.github/scripts/typing_stats_compare.rb new file mode 100644 index 00000000000..50c2a0989cb --- /dev/null +++ b/.github/scripts/typing_stats_compare.rb @@ -0,0 +1,221 @@ +#!/usr/bin/env ruby + +# frozen_string_literal: true + +require "json" + +head_stats = JSON.parse(File.read(ENV["CURRENT_STATS_PATH"]), symbolize_names: true) +base_stats = JSON.parse(File.read(ENV["BASE_STATS_PATH"]), symbolize_names: true) + +def format_for_code_block(data) + data.map do |item| + formatted_string = +"#{item[:path]}:#{item[:line]}" + formatted_string << "\n└── #{item[:line_content]}" if item[:line_content] + formatted_string + end.join("\n") +end + +def pluralize(word, suffix = "s") + "#{word}#{suffix}" +end + +def concord(word, count, suffix = "s") + (count > 1) ? pluralize(word, suffix) : word +end + +def create_intro( + added:, + removed:, + data_name:, + added_partially: [], + removed_partially: [], + data_name_partially: nil, + base_percentage: nil, + head_percentage: nil, + percentage_data_name: nil +) + intro = +"This PR " + intro << "introduces " if added.any? || added_partially.any? + intro << "**#{added.size}** #{concord(data_name, added.size)}" if added.any? + intro << " and " if added.any? && added_partially.any? + intro << "**#{added_partially.size}** #{concord(data_name_partially, added_partially.size)}" if added_partially.any? + intro << ", and " if (added.any? || added_partially.any?) && (removed.any? || removed_partially.any?) + intro << "clears " if removed.any? || removed_partially.any? + intro << "**#{removed.size}** #{concord(data_name, removed.size)}" if removed.any? + intro << " and " if removed.any? && removed_partially.any? + intro << "**#{removed_partially.size}** #{concord(data_name_partially, removed_partially.size)}" if removed_partially.any? + if base_percentage != head_percentage + intro << ". It #{(base_percentage > head_percentage) ? "decreases" : "increases"} " + intro << "the percentage of #{pluralize(percentage_data_name)} from #{base_percentage}% to #{head_percentage}% " + intro << "(**#{"+" if head_percentage > base_percentage}#{(head_percentage - base_percentage).round(2)}**%)" + end + intro << "." + intro +end + +def create_summary( + added:, + removed:, + data_name:, + added_partially: [], + removed_partially: [], + data_name_partially: nil, + base_percentage: nil, + head_percentage: nil, + percentage_data_name: nil +) + return [nil, 0] if added.empty? && removed.empty? && added_partially.empty? && removed_partially.empty? + + intro = create_intro( + added: added, + removed: removed, + data_name: data_name, + added_partially: added_partially, + removed_partially: removed_partially, + data_name_partially: data_name_partially, + base_percentage: base_percentage, + head_percentage: head_percentage, + percentage_data_name: percentage_data_name + ) + + summary = +"### #{pluralize(data_name).capitalize}\n" + summary << "#{intro}\n" + if added.any? || removed.any? + summary << "
#{pluralize(data_name).capitalize} (+#{added&.size || 0}-#{removed.size || 0})\n" + if added.any? + summary << " ❌ Introduced:\n" + summary << "
#{format_for_code_block(added)}
\n" + end + if removed.any? + summary << " ✅ Cleared:\n" + summary << "
#{format_for_code_block(removed)}
\n" + end + summary << "
\n" + end + if added_partially.any? || removed_partially.any? + summary << "
#{pluralize(data_name_partially).capitalize} (+#{added_partially.size || 0}-#{removed_partially.size || 0})\n" + if added_partially.any? + summary << " ❌ Introduced:\n" + summary << "
#{format_for_code_block(added_partially)}
\n" + end + if removed_partially.any? + summary << " ✅ Cleared:\n" + summary << "
#{format_for_code_block(removed_partially)}
\n" + end + summary << "
\n" + end + summary << "\n" + total_introduced = (added&.size || 0) + (added_partially&.size || 0) + [summary, total_introduced] +end + +def ignored_files_summary(head_stats, base_stats) + # This will skip the summary if files are added/removed from contrib folders for now. + ignored_files_added = head_stats[:ignored_files] - base_stats[:ignored_files] + ignored_files_removed = base_stats[:ignored_files] - head_stats[:ignored_files] + + return [nil, 0] if ignored_files_added.empty? && ignored_files_removed.empty? + + typed_files_percentage_base = ((base_stats[:total_files_size] - base_stats[:ignored_files].size) / base_stats[:total_files_size].to_f * 100).round(2) + typed_files_percentage_head = ((head_stats[:total_files_size] - head_stats[:ignored_files].size) / head_stats[:total_files_size].to_f * 100).round(2) + + intro = create_intro( + added: ignored_files_added, + removed: ignored_files_removed, + data_name: "ignored file", + base_percentage: typed_files_percentage_base, + head_percentage: typed_files_percentage_head, + percentage_data_name: "typed file" + ) + + summary = +"### Ignored files\n" + summary << "#{intro}\n" + summary << "
Ignored files (+#{ignored_files_added&.size || 0}-#{ignored_files_removed&.size || 0})\n" + if ignored_files_added.any? + summary << " ❌ Introduced:\n" + summary << "
#{ignored_files_added.join("\n")}
\n" + end + if ignored_files_removed.any? + summary << " ✅ Cleared:\n" + summary << "
#{ignored_files_removed.join("\n")}
\n" + end + summary << "
\n" + summary << "\n" + total_introduced = ignored_files_added&.size || 0 + [summary, total_introduced] +end + +def steep_ignore_summary(head_stats, base_stats) + steep_ignore_added = head_stats[:steep_ignore_comments] - base_stats[:steep_ignore_comments] + steep_ignore_removed = base_stats[:steep_ignore_comments] - head_stats[:steep_ignore_comments] + + create_summary( + added: steep_ignore_added, + removed: steep_ignore_removed, + data_name: "steep:ignore comment" + ) +end + +def untyped_methods_summary(head_stats, base_stats) + untyped_methods_added = head_stats[:untyped_methods] - base_stats[:untyped_methods] + untyped_methods_removed = base_stats[:untyped_methods] - head_stats[:untyped_methods] + partially_typed_methods_added = head_stats[:partially_typed_methods] - base_stats[:partially_typed_methods] + partially_typed_methods_removed = base_stats[:partially_typed_methods] - head_stats[:partially_typed_methods] + total_methods_base = base_stats[:typed_methods_size] + base_stats[:untyped_methods].size + base_stats[:partially_typed_methods].size + total_methods_head = head_stats[:typed_methods_size] + head_stats[:untyped_methods].size + head_stats[:partially_typed_methods].size + typed_methods_percentage_base = (base_stats[:typed_methods_size] / total_methods_base.to_f * 100).round(2) + typed_methods_percentage_head = (head_stats[:typed_methods_size] / total_methods_head.to_f * 100).round(2) + + create_summary( + added: untyped_methods_added, + removed: untyped_methods_removed, + data_name: "untyped method", + added_partially: partially_typed_methods_added, + removed_partially: partially_typed_methods_removed, + data_name_partially: "partially typed method", + base_percentage: typed_methods_percentage_base, + head_percentage: typed_methods_percentage_head, + percentage_data_name: "typed method" + ) +end + +def untyped_others_summary(head_stats, base_stats) + untyped_others_added = head_stats[:untyped_others] - base_stats[:untyped_others] + untyped_others_removed = base_stats[:untyped_others] - head_stats[:untyped_others] + partially_typed_others_added = head_stats[:partially_typed_others] - base_stats[:partially_typed_others] + partially_typed_others_removed = base_stats[:partially_typed_others] - head_stats[:partially_typed_others] + total_others_base = base_stats[:typed_others_size] + base_stats[:untyped_others].size + base_stats[:partially_typed_others].size + total_others_head = head_stats[:typed_others_size] + head_stats[:untyped_others].size + head_stats[:partially_typed_others].size + typed_others_percentage_base = (base_stats[:typed_others_size] / total_others_base.to_f * 100).round(2) + typed_others_percentage_head = (head_stats[:typed_others_size] / total_others_head.to_f * 100).round(2) + + create_summary( + added: untyped_others_added, + removed: untyped_others_removed, + data_name: "untyped other declaration", + added_partially: partially_typed_others_added, + removed_partially: partially_typed_others_removed, + data_name_partially: "partially typed other declaration", + base_percentage: typed_others_percentage_base, + head_percentage: typed_others_percentage_head, + percentage_data_name: "typed other declaration" + ) +end + +# Later we will make the CI fail if there's a regression in the typing stats +ignored_files_summary, _ignored_files_added = ignored_files_summary(head_stats, base_stats) +steep_ignore_summary, _steep_ignore_added = steep_ignore_summary(head_stats, base_stats) +untyped_methods_summary, untyped_methods_added = untyped_methods_summary(head_stats, base_stats) +untyped_others_summary, untyped_others_added = untyped_others_summary(head_stats, base_stats) +result = +"" +result << ignored_files_summary if ignored_files_summary +if steep_ignore_summary || untyped_methods_summary || untyped_others_summary + result << "*__Note__: Ignored files are excluded from the next sections.*\n\n" +end +result << steep_ignore_summary if steep_ignore_summary +result << untyped_methods_summary if untyped_methods_summary +result << untyped_others_summary if untyped_others_summary +if untyped_methods_added > 0 || untyped_others_added > 0 + result << "*If you believe a method or an attribute is rightfully untyped or partially typed, you can add `# untyped:accept` to the end of the line to remove it from the stats.*\n" +end +print result diff --git a/.github/scripts/typing_stats.rb b/.github/scripts/typing_stats_compute.rb similarity index 87% rename from .github/scripts/typing_stats.rb rename to .github/scripts/typing_stats_compute.rb index 0ae4a5756e3..e5e89e9d0b2 100755 --- a/.github/scripts/typing_stats.rb +++ b/.github/scripts/typing_stats_compute.rb @@ -1,8 +1,8 @@ #!/usr/bin/env ruby -require 'steep' -require 'parser/ruby25' -require 'json' +require "steep" +require "parser/ruby25" +require "json" METHOD_AND_PARAM_NAME = /(?:\w*|`[^`]+`)/ PARAMETER = /(?:\*{1,2})?\s*(?:\??\s*untyped\s*\??\s*|\??#{METHOD_AND_PARAM_NAME}:\s*untyped\s*\??)\s*#{METHOD_AND_PARAM_NAME}/ @@ -10,24 +10,30 @@ PROTOTYPE_INITIALIZE = /\s*(?:public|private)?\s*def\s+initialize:\s*#{PARAMETERS}(?:\s*\??\{\s*#{PARAMETERS}\s*->\s*untyped\s*\})?\s*->\s*void/ PROTOTYPE_METHOD = /\s*(?:public|private)?\s*def\s+(?:self\??\.)?(?:[^\s]+):\s*#{PARAMETERS}(?:\s*\??\{\s*#{PARAMETERS}\s*->\s*untyped\s*\})?\s*->\s*untyped/ -# TODO: Find untyped/partially typed attributes, instance variables, class variables, constants - -steepfile_path = Pathname(ENV['STEEPFILE_PATH']) +steepfile_path = Pathname(ENV["STEEPFILE_PATH"]) project = Steep::Project.new(steepfile_path: steepfile_path).tap do |project| Steep::Project::DSL.parse(project, steepfile_path.read, filename: steepfile_path.to_s) end datadog_target = project.targets&.find { |target| target.name == :datadog } loader = ::Steep::Services::FileLoader.new(base_dir: project.base_dir) -ignored_paths = datadog_target&.source_pattern&.ignores +ignored_paths_with_folders = datadog_target&.source_pattern&.ignores + +ignored_files = ignored_paths_with_folders.each_with_object([]) do |ignored_path, result| + # If the ignored path is a folder, add all the .rb files in the folder to the ignored paths + if ignored_path.end_with?("/") + result.push(*Dir.glob(ignored_path + "**/*.rb")) + else + result.push(ignored_path) + end +end # List signature files that are not related to ignored files signature_paths_with_ignored_files = loader.each_path_in_patterns(datadog_target.signature_pattern) signature_paths = signature_paths_with_ignored_files.reject do |sig_path| - # replace sig/ with lib/ and .rbs with .rb - corresponding_lib_file = sig_path.to_s.sub(/^sig/, 'lib').sub(/\.rbs$/, '.rb') - ignored_paths.any? do |ignored| - if ignored.end_with?('/') + corresponding_lib_file = sig_path.to_s.sub(/^sig/, "lib").sub(/\.rbs$/, ".rb") + ignored_paths_with_folders.any? do |ignored| + if ignored.end_with?("/") # Directory ignore - check if signature file is inside this directory corresponding_lib_file.start_with?(ignored) else @@ -37,14 +43,6 @@ end end -# Ignored files stats -ignored_files_size = ignored_paths.inject(0) do |result, path| - if path.end_with?('/') - result + Dir.glob(path + '**/*.rb').size - else - result + 1 - end -end total_files_size = Dir.glob("#{project.base_dir}/lib/**/*.rb").size # steep:ignore comments stats @@ -121,10 +119,7 @@ resulting_stats = { total_files_size: total_files_size, - ignored_files: { - size: ignored_files_size, # Required as we don't list all ignored files, but only their paths - paths: ignored_paths - }, + ignored_files: ignored_files, steep_ignore_comments: ignore_comments, diff --git a/.github/workflows/typing-stats.yml b/.github/workflows/typing-stats.yml index e316cfcd2e4..f7d3e77cc6b 100644 --- a/.github/workflows/typing-stats.yml +++ b/.github/workflows/typing-stats.yml @@ -33,7 +33,7 @@ jobs: return undefined === comment ? null : comment.id - - name: Checkout code + - name: Checkout current branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -41,18 +41,42 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0 with: - ruby-version: "3.3" + ruby-version: "3.4" - name: Install steep - run: gem install steep -v 1.9.1 + run: gem install steep -v 1.10 - - name: Run typing stats - id: typing-stats + - name: Copy scripts to directory outside of workspace + run: | + cp .github/scripts/typing_stats_compute.rb ${{ runner.temp }}/typing_stats_compute.rb + cp .github/scripts/typing_stats_compare.rb ${{ runner.temp }}/typing_stats_compare.rb + + - name: Run typing stats on current branch + id: typing-stats-current env: STEEPFILE_PATH: ${{ github.workspace }}/Steepfile run: | mkdir -p "${{ github.workspace }}/tmp" - ruby .github/scripts/typing_stats.rb >> "${{ github.workspace }}/tmp/typing-stats.json" + ruby ${{ runner.temp }}/typing_stats_compute.rb >> "${{ runner.temp }}/typing-stats-current.json" + + - name: Checkout base branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.ref }} + + - name: Run typing stats on base branch + id: typing-stats-base + env: + STEEPFILE_PATH: ${{ github.workspace }}/Steepfile + run: ruby ${{ runner.temp }}/typing_stats_compute.rb >> "${{ runner.temp }}/typing-stats-base.json" + + - name: Run typing stats compare + id: typing-stats-compare + env: + CURRENT_STATS_PATH: ${{ runner.temp }}/typing-stats-current.json + BASE_STATS_PATH: ${{ runner.temp }}/typing-stats-base.json + run: ruby ${{ runner.temp }}/typing_stats_compare.rb >> "${{ runner.temp }}/typing-stats-compare.md" - name: Write comment uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 @@ -62,65 +86,24 @@ jobs: var fs = require('fs') const previousCommentId = ${{steps.comment.outputs.result}} - const stats = JSON.parse(fs.readFileSync("${{ github.workspace }}/tmp/typing-stats.json", "utf8")) - - const typedFilesPercentage = ((stats.total_files_size - stats.ignored_files.size) / stats.total_files_size * 100).toFixed(2) - - const totalMethods = stats.untyped_methods.length + stats.partially_typed_methods.length + stats.typed_methods_size - const typedMethodsPercentage = (stats.typed_methods_size / totalMethods * 100).toFixed(2) - - const totalOthers = stats.untyped_others.length + stats.partially_typed_others.length + stats.typed_others_size - const typedOthersPercentage = (stats.typed_others_size / totalOthers * 100).toFixed(2) - + const commentContent = fs.readFileSync("${{ runner.temp }}/typing-stats-compare.md", "utf8") const commentBody = ` ## Typing analysis - - ### Ignored files - There are **${stats.ignored_files.size}** ignored files in the Steepfile out of ${stats.total_files_size}. - **${typedFilesPercentage}%** of the codebase is type checked. -
Ignored files -
${stats.ignored_files.paths.map((path) => `${path}`).join('\n')}
-
- - *__Note__: Ignored files are excluded from the next sections.* - - ### \`steep:ignore\` comments - There are **${stats.steep_ignore_comments.length}** \`steep:ignore\` comments in the codebase. -
steep:ignore comments -
${stats.steep_ignore_comments.map((comment) => `${comment.path}:${comment.line}`).join('\n')}
-
- - ### Untyped methods - There are **${stats.untyped_methods.length}** untyped and **${stats.partially_typed_methods.length}** partially typed methods out of ${totalMethods}. - **${typedMethodsPercentage}%** of the methods are typed. -
Untyped methods -
${stats.untyped_methods.map((method) => `${method.path}:${method.line}\n└── ${method.line_content}`).join('\n')}
-
-
Partially typed methods -
${stats.partially_typed_methods.map((method) => `${method.path}:${method.line}\n└── ${method.line_content}`).join('\n')}
-
- - ### Untyped attributes, constants, globals, instance variables - There are **${stats.untyped_others.length}** untyped and **${stats.partially_typed_others.length}** partially typed attributes, constants, globals, instance variables out of **${totalOthers}**. - **${typedOthersPercentage}%** of them are typed. -
Untyped attributes, constants, globals, instance variables -
${stats.untyped_others.map((other) => `${other.path}:${other.line}\n└── ${other.line_content}`).join('\n')}
-
-
Partially typed attributes, constants, globals, instance variables -
${stats.partially_typed_others.map((other) => `${other.path}:${other.line}\n└── ${other.line_content}`).join('\n')}
-
- - *If you believe a method or an attribute is rightfully untyped or partially typed, you can add \`# untyped:accept\` to the end of the line to remove it from the stats.* + ${commentContent} + ` + const thankYouBody = ` + ## Typing analysis + *This PR does not change typing compared to the base branch.* ` const options = { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, - body: commentBody + body: commentContent !== "" ? commentBody : thankYouBody } - if (null === previousCommentId) { + if (null === previousCommentId && commentContent !== "") { await github.rest.issues.createComment(options) } else { await github.rest.issues.updateComment({...options, comment_id: previousCommentId})