Skip to content

Commit 377eef6

Browse files
authored
Merge pull request #22283 from Homebrew/rubocop-public-api-checks
Move API checks into RuboCop
2 parents 0410293 + 7d485a4 commit 377eef6

8 files changed

Lines changed: 154 additions & 104 deletions

File tree

.github/workflows/tests.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,6 @@ jobs:
6565

6666
- run: brew typecheck
6767

68-
- name: Check public API lists are in sync
69-
working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }}
70-
run: brew ruby Library/Homebrew/github_actions_utils/public_api_check.rb
71-
7268
tap-syntax:
7369
name: tap syntax
7470
needs: syntax

Library/Homebrew/.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Naming/MethodParameterName:
3737
- uv
3838

3939
# Only enforce documentation for public APIs.
40-
# Checked by the tests.yml syntax job
40+
# Include list checked by Homebrew/PublicApiDocumentation.
4141
Style/Documentation:
4242
AllowedConstants:
4343
- Homebrew

Library/Homebrew/github_actions_utils/public_api_check.rb

Lines changed: 0 additions & 94 deletions
This file was deleted.

Library/Homebrew/rubocops/public_api_cookbook.rb

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ module Homebrew
1111
# definitions.
1212
#
1313
# Both cookbook method lists live in {ApiAnnotationHelper} and are
14-
# validated by `.github/check_cookbook_method_lists.rb` in CI.
14+
# validated by this cop.
1515
class PublicApiCookbook < Base
1616
MSG = "Method `%<method>s` is referenced in the %<cookbook>s but is not annotated with `@api public`."
17+
MISSING_FORMULA_LIST_MSG = "Formula Cookbook references methods missing from " \
18+
"`FORMULA_COOKBOOK_METHODS`: %<methods>s."
19+
MISSING_CASK_LIST_MSG = "Method `%<method>s` is annotated with `@api public` in `%<file>s` but is " \
20+
"missing from `CASK_COOKBOOK_METHODS`."
1721

1822
sig { void }
1923
def on_new_investigation
@@ -23,12 +27,61 @@ def on_new_investigation
2327
return if file_path.nil?
2428

2529
relative_path = file_path.sub(%r{.*/Library/Homebrew/}, "")
30+
31+
if relative_path == "rubocops/shared/api_annotation_helper.rb"
32+
missing_formula = (HOMEBREW_LIBRARY_PATH.parent.parent/"docs/Formula-Cookbook.md").read
33+
.scan(
34+
%r{/rubydoc/\w+(?:/\w+)*\.html#(\w+[!?]?)-(?:class|instance)_method},
35+
)
36+
.flatten -
37+
ApiAnnotationHelper::FORMULA_COOKBOOK_METHODS.keys
38+
missing_formula.sort!
39+
40+
if missing_formula.any?
41+
add_offense(
42+
processed_source.ast&.each_descendant(:casgn)&.find do |node|
43+
node.const_name == "FORMULA_COOKBOOK_METHODS"
44+
end || processed_source.ast || processed_source.buffer.source_range,
45+
message: format(
46+
MISSING_FORMULA_LIST_MSG,
47+
methods: missing_formula.map { |method| "`#{method}`" }.join(", "),
48+
),
49+
)
50+
end
51+
52+
return
53+
end
54+
2655
api_public_targets = build_api_public_targets
2756

2857
check_cookbook_methods(ApiAnnotationHelper::FORMULA_COOKBOOK_METHODS,
2958
"Formula Cookbook", relative_path, api_public_targets)
3059
check_cookbook_methods(ApiAnnotationHelper::CASK_COOKBOOK_METHODS,
3160
"Cask Cookbook", relative_path, api_public_targets)
61+
62+
return unless %w[cask/dsl.rb cask/cask.rb cask/dsl/version.rb].include?(relative_path)
63+
64+
cookbook_methods = ApiAnnotationHelper::CASK_COOKBOOK_METHODS.keys.to_set
65+
lines = processed_source.lines
66+
67+
processed_source.comments.each do |comment|
68+
next unless ["# @api public", "@api public"].include?(comment.text.strip)
69+
70+
(1..5).each do |offset|
71+
target_line = lines[comment.loc.line - 1 + offset]&.strip
72+
break if target_line.blank?
73+
74+
match = target_line.match(/\A(?:def\s+(?:self\.)?|attr_reader\s+:|attr_accessor\s+:)(\w+[!?]?)/) ||
75+
target_line.match(/\Adelegate\s+(\w+[!?]?):/)
76+
next if match.nil?
77+
78+
method_name = match[1].to_s
79+
break if cookbook_methods.include?(method_name)
80+
81+
add_offense(comment, message: format(MISSING_CASK_LIST_MSG, method: method_name, file: relative_path))
82+
break
83+
end
84+
end
3285
end
3386

3487
private
@@ -37,7 +90,7 @@ def on_new_investigation
3790
# preceded by an `@api public` annotation in their doc block.
3891
# Walks forward from each `@api public` comment to find the next
3992
# def/attr_reader/delegate, matching only the immediately following
40-
# definition not one 20 lines away.
93+
# definition; not one 20 lines away.
4194
sig { returns(T::Set[Integer]) }
4295
def build_api_public_targets
4396
targets = T.let(Set.new, T::Set[Integer])

Library/Homebrew/rubocops/public_api_documentation.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,36 @@ module Homebrew
2626
# ```
2727
class PublicApiDocumentation < Base
2828
MSG = "`@api public` methods must have a descriptive YARD comment, not just the annotation."
29+
MISSING_INCLUDE_MSG = "`%<file>s` contains `@api public` but is missing from `Style/Documentation.Include`."
30+
EXTRA_INCLUDE_MSG = "`%<file>s` is included in `Style/Documentation.Include` but does not contain " \
31+
"`@api public`."
2932

3033
sig { void }
3134
def on_new_investigation
3235
super
3336

34-
processed_source.comments.each do |comment|
37+
comments = processed_source.comments
38+
comments.each do |comment|
3539
next unless api_public_comment?(comment)
3640

3741
add_offense(comment) unless descriptive_comment_preceding?(comment)
3842
end
43+
44+
documentation_include = config.dig("Style/Documentation", "Include")
45+
file_path = processed_source.file_path
46+
return if documentation_include.nil? || file_path.nil?
47+
48+
api_public_comments = comments.select { |comment| api_public_comment?(comment) }
49+
relative_path = file_path.sub(%r{.*/Library/Homebrew/}, "")
50+
included = Array(documentation_include).include?(relative_path)
51+
if api_public_comments.any? && !included
52+
add_offense(api_public_comments.first, message: format(MISSING_INCLUDE_MSG, file: relative_path))
53+
elsif api_public_comments.empty? && included
54+
add_offense(
55+
processed_source.ast || processed_source.buffer.source_range,
56+
message: format(EXTRA_INCLUDE_MSG, file: relative_path),
57+
)
58+
end
3959
end
4060

4161
private

Library/Homebrew/rubocops/shared/api_annotation_helper.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ module ApiAnnotationHelper
2222

2323
# Methods documented in docs/Formula-Cookbook.md mapped to their
2424
# defining source files (relative to Library/Homebrew/).
25-
# Validated by `github_actions_utils/public_api_check.rb`
25+
# Validated by `Homebrew/PublicApiCookbook`
2626
# against rubydoc links in `docs/Formula-Cookbook.md`.
2727
FORMULA_COOKBOOK_METHODS = T.let({
2828
"cd" => "extend/pathname.rb",
@@ -59,7 +59,7 @@ module ApiAnnotationHelper
5959

6060
# Methods documented in docs/Cask-Cookbook.md mapped to their
6161
# defining source files (relative to Library/Homebrew/).
62-
# Validated by `github_actions_utils/public_api_check.rb`
62+
# Validated by `Homebrew/PublicApiCookbook`
6363
# against `@api public` annotations in cask source files.
6464
CASK_COOKBOOK_METHODS = T.let({
6565
"after_comma" => "cask/dsl/version.rb",

Library/Homebrew/test/rubocops/public_api_cookbook_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
let(:formula_path) { "formula.rb" }
1010
let(:cask_dsl_path) { "cask/dsl.rb" }
11+
let(:helper_path) { "rubocops/shared/api_annotation_helper.rb" }
1112

1213
context "when a cookbook-referenced method lacks `@api public`" do
1314
it "reports an offense for a formula method" do
@@ -82,4 +83,48 @@ def desc; end
8283
RUBY
8384
end
8485
end
86+
87+
context "when a formula cookbook method is missing from the helper list" do
88+
before do
89+
(mktmpdir/"docs").tap do |docs|
90+
docs.mkpath
91+
(docs/"Formula-Cookbook.md").write <<~MARKDOWN
92+
[`new_api`](/rubydoc/Formula.html#new_api-instance_method)
93+
MARKDOWN
94+
95+
stub_const("HOMEBREW_LIBRARY_PATH", docs.parent/"Library/Homebrew")
96+
end
97+
98+
stub_const("RuboCop::Cop::ApiAnnotationHelper::FORMULA_COOKBOOK_METHODS", {})
99+
end
100+
101+
it "reports an offense" do
102+
expect_offense(<<~RUBY, helper_path)
103+
module ApiAnnotationHelper
104+
FORMULA_COOKBOOK_METHODS = {}.freeze
105+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Homebrew/PublicApiCookbook: Formula Cookbook references methods missing from `FORMULA_COOKBOOK_METHODS`: `new_api`.
106+
end
107+
RUBY
108+
end
109+
end
110+
111+
context "when a public cask method is missing from the helper list" do
112+
before do
113+
stub_const("RuboCop::Cop::ApiAnnotationHelper::CASK_COOKBOOK_METHODS", {})
114+
end
115+
116+
it "reports an offense" do
117+
expect_offense(<<~RUBY, cask_dsl_path)
118+
module Cask
119+
module DSL
120+
# The new stanza.
121+
#
122+
# @api public
123+
^^^^^^^^^^^^^ Homebrew/PublicApiCookbook: Method `new_stanza` is annotated with `@api public` in `cask/dsl.rb` but is missing from `CASK_COOKBOOK_METHODS`.
124+
def new_stanza; end
125+
end
126+
end
127+
RUBY
128+
end
129+
end
85130
end

Library/Homebrew/test/rubocops/public_api_documentation_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,34 @@ def prefix; end
9393
RUBY
9494
end
9595
end
96+
97+
context "when a public API file is missing from `Style/Documentation.Include`" do
98+
subject(:cop) do
99+
described_class.new(RuboCop::Config.new("Style/Documentation" => { "Include" => [] }))
100+
end
101+
102+
it "reports an offense" do
103+
expect_offense(<<~RUBY, "public_api.rb")
104+
# The public method.
105+
#
106+
# @api public
107+
^^^^^^^^^^^^^ `public_api.rb` contains `@api public` but is missing from `Style/Documentation.Include`.
108+
def foo; end
109+
RUBY
110+
end
111+
end
112+
113+
context "when a documented API file has no public API annotations" do
114+
subject(:cop) do
115+
described_class.new(RuboCop::Config.new("Style/Documentation" => { "Include" => ["stale.rb"] }))
116+
end
117+
118+
it "reports an offense" do
119+
expect_offense(<<~RUBY, "stale.rb")
120+
class Stale
121+
^^^^^^^^^^^ `stale.rb` is included in `Style/Documentation.Include` but does not contain `@api public`.
122+
end
123+
RUBY
124+
end
125+
end
96126
end

0 commit comments

Comments
 (0)