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})