danger-packwerk 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cf899d973a7ead8ff19dc6f73f30f63c23bdc7fffc3f043766d8819ffbc088aa
4
+ data.tar.gz: 318d88c5243e9e510abef74590fb907ba97dd10afd563cd9fcebb1eea9161f40
5
+ SHA512:
6
+ metadata.gz: 33fb31226007f52ddf1248e0d764816014f2371a5e48e1ba981ce6947c6f64eb6b3368e2ac628ea5912c5ec565481f3887e5fd53618ac5227816d27009034769
7
+ data.tar.gz: 8d391aea94c4045f26eb5232a177a35ebe597bf62239ab8e1050458b8db89739c2538a7f68cc5791d2b956f01986a1fb9ea23d4e5c72a53dc25e4aa965bbe013
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # danger-packwerk
2
+
3
+ `danger-packwerk` integrates [`packwerk`](https://github.com/Shopify/packwerk) with [`danger`](https://github.com/danger/danger) to provide inline comments in PRs related to boundaries in a Rails application.
4
+
5
+ ## Installation
6
+ Step 1: Add this line to your `Gemfile` (to whatever group your CI uses, as it is not needed in production) and `bundle install`:
7
+
8
+ ```ruby
9
+ gem 'danger-packwerk', group: :test
10
+ ```
11
+
12
+ Step 2: Add these to your `Dangerfile`:
13
+
14
+ ```ruby
15
+ packwerk.check
16
+ deprecated_references_yml_changes.check
17
+ ```
18
+
19
+ That's it for basic usage!
20
+
21
+ ## Usage
22
+
23
+ There are currently two danger checks that ship with `danger-packwerk`:
24
+ 1) One that runs `bin/packwerk check` and leaves inline comments in source code on new violations
25
+ 2) One that looks at changes to `deprecated_references.yml` files and leaves inline comments on added violations.
26
+
27
+ In upcoming iterations, we will include other danger checks, including:
28
+ 1) A danger check that detects changes to `package.yml` files and posts user-configurable messages on the `package.yml` files that are modified.
29
+ 2) A danger check that detects changes to `packwerk.yml` files and allows you to specify the action taken when that happens.
30
+
31
+ ## packwerk.check
32
+ ![This is an image displaying a comment from the Danger github bot. The comment is inline with the PR in Github and displays the following text. Dependency violation: ::FeatureFlag belongs to 'packs/feature_flags', but 'packs/gusto_slack' does not specify a dependency on 'packs/feature_flags'. Are we missing an abstraction? Is the code making the reference, and the referenced constant, in the right packages? Inference details: this is a reference to ::FeatureFlag which seems to be defined in packs/feature_flags/app/models/feature_flag.rb. To receive help interpreting or resolving this error message, see: https://github.com/Shopify/packwerk/blob/main/TROUBLESHOOT.md#Troubleshooting-violations Privacy violation: '::FeatureFlag' is private to 'packs/feature_flags' but referenced from 'packs/gusto_slack'. Is there a public entrypoint in 'packs/feature_flags/app/public/' that you can use instead? Inference details: this is a reference to ::FeatureFlag which seems to be defined in packs/feature_flags/app/models/feature_flag.rb. To receive help interpreting or resolving this error message, see: https://github.com/Shopify/packwerk/blob/main/TROUBLESHOOT.md#Troubleshooting-violations](docs/basic_usage.png)
33
+
34
+ Without any configuration, `packwerk.check` should just work. By default, it will post a maximum of 15 messages in a PR, using the default messaging from packwerk, and it will not fail the build.
35
+
36
+ `packwerk.check` can be configured to in the following ways:
37
+
38
+ ### Change the message that displays in the markdown
39
+ The default message displayed is from `bin/packwerk check`. To customize this this, pass in `offenses_formatter` to `packwerk.check` in your `Dangerfile`. Here's a simple example:
40
+ ```ruby
41
+ packwerk.check(
42
+ # Offenses are a T::Array[Packwerk::ReferenceOffense] => https://github.com/Shopify/packwerk/blob/main/lib/packwerk/reference_offense.rb
43
+ offenses_formatter: -> (offenses) do
44
+ "There are #{offenses.count} packwerk offenses on this line!"
45
+ end
46
+ )
47
+ ```
48
+
49
+ A more advanced example could give more specific information about the violation and information specific to your organization or project about how to resolve. Here is a screenshot of what our message looks like at Gusto:
50
+ ![This is an image displaying a comment from the Danger github bot. The comment is inline with the PR in Github and displays the following text: Hi there! It looks like FeatureFlag is private API of packs/feature_flags, which is also not in packs/gusto_slack's list of dependencies. Before you run bin/packwerk update-deprecations, read through How to Handle Dependency and Privacy Violations (with Flow Chart!). Here are some quick suggestions to resolve: Does the code you are writing live in the right pack? If not, try bin/move_to_pack -n packs/destination_pack -f packs/gusto_slack/app/services/slack/client.rb. Does FeatureFlag live in the right pack? If not, try bin/move_to_pack -n packs/destination_pack -f packs/feature_flags/app/models/feature_flag.rb. Do we actually want to depend on packs/feature_flags. If so, try adding packs/feature_flags to packs/gusto_slack/package.yml dependencies. If not, what can we change about the design so we do not have to depend on packs/feature_flags? Does API in packs/feature_flags/public support this use case? If not, can we work with @Gusto/product-infrastructure to create and use a public API? If FeatureFlag should already be public, try bin/make_public -f packs/feature_flags/app/models/feature_flag.rb. Need help? Join us in #ruby-modularity or provide feedback.](docs/advanced_usage.png)
51
+
52
+ ### Fail the build on new violations
53
+ Simply pass in `fail_build: true` into `check`, as such:
54
+ ```ruby
55
+ packwerk.check(fail_build: true)
56
+ ```
57
+
58
+ If you want to change the default error message, which is `Packwerk violations were detected! Please resolve them to unblock the build.`, then you can also pass in `failure_message`.
59
+
60
+ ### Change the max number of comments that will display
61
+ If you do not change this, the default max is 15. More information about why we chose this number in the source code.
62
+ ```ruby
63
+ packwerk.check(max_comments: 3)
64
+ ```
65
+
66
+ ### Do something extra when there are packwerk failures
67
+ Maybe you want to notify slack or do something else when there are packwerk failures.
68
+
69
+ ```ruby
70
+ packwerk.check(
71
+ # Offenses are a T::Array[Packwerk::ReferenceOffense] => https://github.com/Shopify/packwerk/blob/main/lib/packwerk/reference_offense.rb
72
+ on_failure: -> (offenses) do
73
+ # Notify slack or otherwise do something extra!
74
+ end
75
+ )
76
+ ```
77
+
78
+ ## deprecated_references_yml_changes.check
79
+ ![This is an image displaying a comment from the Danger github bot. The comment is inline with the PR in Github and displays the following text. We noticed you ran `bin/packwerk update-deprecations`. Make sure to read through the docs for other ways to resolve.](docs/basic_usage_2.png)
80
+
81
+ Without any configuration, `deprecated_references_yml_changes.check` should just work. By default, it will post a maximum of 15 messages in a PR, using default messaging defined within this gem.
82
+
83
+ `deprecated_references_yml_changes.check` can be configured to in the following ways:
84
+
85
+ ### Change the message that displays in the markdown
86
+ The default message displayed is from `lib/danger-packwerk/private/default_offenses_formatter.rb`. To customize this this, pass in `added_offenses_formatter` to `deprecated_references_yml_changes.check` in your `Dangerfile`. Here's a simple example:
87
+ ```ruby
88
+ deprecated_references_yml_changes.check(
89
+ # Offenses are a T::Array[DangerPackwerk::BasicReferenceOffense]
90
+ added_offenses_formatter: -> (added_offenses) do
91
+ "There are #{added_offenses.count} new violations this line!"
92
+ end
93
+ )
94
+ ```
95
+
96
+ A more advanced example could give more specific information about the violation and information specific to your organization or project about how to resolve. Here is a screenshot of what our message looks like at Gusto:
97
+ ![This is an image displaying a comment from the Danger github bot. The comment is inline with the PR in Github and displays the following text. Hi again! It looks like FeatureFlag is private API of packs/feature_flags, which is also not in packs/gusto_slack's list of dependencies.. We noticed you ran bin/packwerk update-deprecations. Make sure to read through How to Handle Dependency and Privacy Violations (with Flow Chart!). Could you add some context as a reply here about why we needed to add these violations packs/gusto_slack/package.yml is configured with notify_on_new_violations to notify @Gusto/product-infrastructure (#product-infrastructure) on new dependency violations. packs/feature_flags/package.yml is configured with notify_on_new_violations to notify @Gusto/product-infrastructure (#product-infrastructure) on new privacy violations
98
+ Need help? Join us in #ruby-modularity or provide feedback.
99
+ ](docs/advanced_usage_2.png)
100
+
101
+ ### Change the max number of comments that will display
102
+ If you do not change this, the default max is 15. More information about why we chose this number in the source code.
103
+ ```ruby
104
+ deprecated_references_yml_changes.check(max_comments: 3)
105
+ ```
106
+
107
+ ### Do something extra before we leave comments
108
+ Maybe you want to notify slack or do something else before we leave comments.
109
+
110
+ ```ruby
111
+ deprecated_references_yml_changes.check(
112
+ # violation_diff is a DangerPackwerk::ViolationDiff and changed_deprecated_references_ymls is a T::Array[String]
113
+ before_comment: -> (violation_diff, changed_deprecated_references_ymls) do
114
+ # Notify slack or otherwise do something extra!
115
+ end
116
+ )
117
+ ```
118
+
119
+ ## Development
120
+
121
+ We welcome your contributions! Please create an issue or pull request and we'd be happy to take a look.
@@ -0,0 +1,106 @@
1
+ # typed: strict
2
+
3
+ module DangerPackwerk
4
+ #
5
+ # We call this BasicReferenceOffense as it is intended to have a subset of the interface of Packwerk::ReferenceOffense, located here:
6
+ # https://github.com/Shopify/packwerk/blob/a22862b59f7760abf22bda6804d41a52d05301d8/lib/packwerk/reference_offense.rb#L1
7
+ # However, we cannot actually construct a Packwerk::ReferenceOffense from `deprecated_referencs.yml` alone, since they are normally
8
+ # constructed in packwerk when packwerk parses the AST and actually outputs `deprecated_references.yml`, a process in which some information,
9
+ # such as the location where the constant is defined, is lost.
10
+ #
11
+ class BasicReferenceOffense < T::Struct
12
+ class Location < T::Struct
13
+ extend T::Sig
14
+
15
+ const :file, String
16
+ const :line_number, Integer
17
+
18
+ #
19
+ # These two methods exist so we can use `group_by` to group a `T::Array[BasicReferenceOffense]` by location.
20
+ #
21
+ sig { params(other: Location).returns(T::Boolean) }
22
+ def eql?(other)
23
+ file == other.file && line_number == other.line_number
24
+ end
25
+
26
+ sig { returns(Integer) }
27
+ def hash
28
+ [file, line_number].hash
29
+ end
30
+ #
31
+ # End method group
32
+ #
33
+ end
34
+
35
+ extend T::Sig
36
+
37
+ const :class_name, String
38
+ const :file, String
39
+ const :to_package_name, String
40
+ const :type, String
41
+ const :location, Location
42
+
43
+ sig { params(deprecated_references_yml: String).returns(T::Array[BasicReferenceOffense]) }
44
+ def self.from(deprecated_references_yml)
45
+ deprecated_references_yml_pathname = Pathname.new(deprecated_references_yml)
46
+ violations = Private::DeprecatedReferences.from(deprecated_references_yml_pathname).violations
47
+
48
+ violations.flat_map do |violation|
49
+ # We choose the location of the violation as the reference to the constant within the `deprecated_references.yml` file.
50
+ # We simply find the reference to that constant, because we know that each constant reference can occur only once per `deprecated_references.yml` file
51
+ # The reason for this is that we know that only one file in the codebase can define a constant, and packwerk's constant_resolver will actually
52
+ # raise if this assumption is not true: https://github.com/Shopify/constant_resolver/blob/e78af0c8d5782b06292c068cfe4176e016c51b34/lib/constant_resolver.rb#L74
53
+ #
54
+ # Note though that since one constant reference in a `deprecated_referencs.yml` can be both a privacy and a dependency violation AND it can occur in many files,
55
+ # we need to group them. That is -- if `MyPrivateConstant` is both a dependency and a privacy violation AND it occurs in 10 files, that would represent 20 violations.
56
+ # Therefore we will group all of those 20 into one message to the user rather than providing 20 messages.
57
+ _line, line_number = deprecated_references_yml_pathname.readlines.each_with_index.find { |line, _index| line.include?(violation.class_name) }
58
+ if line_number.nil?
59
+ debug_info = { class_name: violation.class_name, to_package_name: violation.to_package_name, type: violation.type }
60
+ raise "Unable to find reference to violation #{debug_info} in #{deprecated_references_yml}"
61
+ end
62
+
63
+ # We add one to the line number since `each_with_index` is zero-based indexed but Github line numbers are one-based indexed
64
+ location = Location.new(file: deprecated_references_yml, line_number: line_number + 1)
65
+
66
+ violation.files.map do |file|
67
+ BasicReferenceOffense.new(
68
+ class_name: violation.class_name,
69
+ file: file,
70
+ to_package_name: violation.to_package_name,
71
+ type: violation.type,
72
+ location: location
73
+ )
74
+ end
75
+ end
76
+ end
77
+
78
+ sig { returns(T::Boolean) }
79
+ def privacy?
80
+ type == 'privacy'
81
+ end
82
+
83
+ sig { returns(T::Boolean) }
84
+ def dependency?
85
+ type == 'dependency'
86
+ end
87
+
88
+ sig { params(other: BasicReferenceOffense).returns(T::Boolean) }
89
+ def ==(other)
90
+ other.class_name == class_name &&
91
+ other.file == file &&
92
+ other.to_package_name == to_package_name &&
93
+ other.type == type
94
+ end
95
+
96
+ sig { params(other: BasicReferenceOffense).returns(T::Boolean) }
97
+ def eql?(other)
98
+ self == other
99
+ end
100
+
101
+ sig { returns(Integer) }
102
+ def hash
103
+ [class_name, file, to_package_name, type].hash
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,140 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'danger'
5
+ require 'sorbet-runtime'
6
+ require 'danger-packwerk/private'
7
+ require 'danger-packwerk/basic_reference_offense'
8
+ require 'danger-packwerk/violation_diff'
9
+ require 'open3'
10
+
11
+ module DangerPackwerk
12
+ class DangerDeprecatedReferencesYmlChanges < Danger::Plugin
13
+ extend T::Sig
14
+
15
+ # We choose 5 here because violation additions tend to fall into a bimodal distribution, where most PRs only add a handful (<10) of new violations,
16
+ # but there are some that do a rename of an often-used variable, which can change hundreds of violations.
17
+ # Therefore we hope to capture the majority case of people making changes to code while not spamming PRs that do a big rename.
18
+ # We set a max (rather than unlimited) to avoid GitHub rate limiting and general spam if a PR does some sort of mass rename.
19
+ DEFAULT_MAX_COMMENTS = 5
20
+ AddedOffensesFormatter = T.type_alias { T.proc.params(added_violations: T::Array[BasicReferenceOffense]).returns(String) }
21
+ DEFAULT_ADDED_OFFENSES_FORMATTER = T.let(->(added_violations) { Private::DefaultAddedOffensesFormatter.format(added_violations) }, AddedOffensesFormatter)
22
+ BeforeComment = T.type_alias { T.proc.params(violation_diff: ViolationDiff, changed_deprecated_references_ymls: T::Array[String]).void }
23
+ DEFAULT_BEFORE_COMMENT = T.let(->(violation_diff, changed_deprecated_references_ymls) {}, BeforeComment)
24
+
25
+ sig do
26
+ params(
27
+ added_offenses_formatter: AddedOffensesFormatter,
28
+ before_comment: BeforeComment,
29
+ max_comments: Integer
30
+ ).void
31
+ end
32
+ def check(
33
+ added_offenses_formatter: DEFAULT_ADDED_OFFENSES_FORMATTER,
34
+ before_comment: DEFAULT_BEFORE_COMMENT,
35
+ max_comments: DEFAULT_MAX_COMMENTS
36
+ )
37
+ changed_deprecated_references_ymls = (git.modified_files + git.added_files + git.deleted_files).grep(DEPRECATED_REFERENCES_PATTERN)
38
+
39
+ violation_diff = get_violation_diff
40
+
41
+ before_comment.call(
42
+ violation_diff,
43
+ changed_deprecated_references_ymls.to_a
44
+ )
45
+
46
+ current_comment_count = 0
47
+ violation_diff.added_violations.group_by(&:location).each do |location, violations|
48
+ break if current_comment_count >= max_comments
49
+
50
+ markdown(
51
+ added_offenses_formatter.call(violations),
52
+ line: location.line_number,
53
+ file: location.file
54
+ )
55
+
56
+ current_comment_count += 1
57
+ end
58
+ end
59
+
60
+ sig { returns(ViolationDiff) }
61
+ def get_violation_diff # rubocop:disable Naming/AccessorMethodName
62
+ added_violations = T.let([], T::Array[BasicReferenceOffense])
63
+ removed_violations = T.let([], T::Array[BasicReferenceOffense])
64
+
65
+ git.added_files.grep(DEPRECATED_REFERENCES_PATTERN).each do |added_deprecated_references_yml_file|
66
+ # Since the file is added, we know on the base commit there are no violations related to this pack,
67
+ # and that all violations from this file are new
68
+ added_violations += BasicReferenceOffense.from(added_deprecated_references_yml_file)
69
+ end
70
+
71
+ git.deleted_files.grep(DEPRECATED_REFERENCES_PATTERN).each do |deleted_deprecated_references_yml_file|
72
+ # Since the file is deleted, we know on the HEAD commit there are no violations related to this pack,
73
+ # and that all violations from this file are deleted
74
+ removed_violations += get_violations_before_patch_for(deleted_deprecated_references_yml_file)
75
+ end
76
+
77
+ git.modified_files.grep(DEPRECATED_REFERENCES_PATTERN).each do |modified_deprecated_references_yml_file|
78
+ head_commit_violations = BasicReferenceOffense.from(modified_deprecated_references_yml_file)
79
+ base_commit_violations = get_violations_before_patch_for(modified_deprecated_references_yml_file)
80
+
81
+ added_violations += head_commit_violations - base_commit_violations
82
+ removed_violations += base_commit_violations - head_commit_violations
83
+ end
84
+
85
+ ViolationDiff.new(
86
+ added_violations: added_violations,
87
+ removed_violations: removed_violations
88
+ )
89
+ end
90
+
91
+ private
92
+
93
+ sig { params(deprecated_references_yml_file: String).returns(T::Array[BasicReferenceOffense]) }
94
+ def get_violations_before_patch_for(deprecated_references_yml_file)
95
+ # The strategy to get the violations before this PR is to reverse the patch on each `deprecated_references.yml`.
96
+ # A previous strategy attempted to use `git merge-base --fork-point`, but there are many situations where it returns
97
+ # empty values. That strategy is fickle because it depends on the state of the `reflog` within the CI suite, which appears
98
+ # to not be reliable to depend on.
99
+ #
100
+ # Instead, just inverting the patch should hopefully provide a more reliable way to figure out what was the state of the file before
101
+ # the PR without needing to use git commands that interpret the branch history based on local git history.
102
+ #
103
+ # We apply the patch to the original file so that we can seamlessly reverse the patch applied to that file (since patches are coupled to
104
+ # the files they modify). After parsing the violations from that `deprecated_references.yml` file with the patch reversed,
105
+ # we use a temporary copy of the original file to rewrite to it with the original contents.
106
+ # Note that practically speaking, we don't need to rewrite the original contents (since we already fetched the
107
+ # original contents above and the CI file system should be ephemeral). However, we do this anyways in case we later change these
108
+ # assumptions, or another client's environment is different and expects these files not to be mutated.
109
+
110
+ # Keep track of the original file contents. If the original file has been deleted, then we delete the file after inverting the patch at the end, rather than rewriting it.
111
+ deprecated_references_yml_file_copy = (File.read(deprecated_references_yml_file) if File.exist?(deprecated_references_yml_file))
112
+
113
+ Tempfile.create do |patch_file|
114
+ # Normally we'd use `git.diff_for_file(deprecated_references_yml_file).patch` here, but there is a bug where it does not work for deleted files yet.
115
+ # I have a fix for that here: https://github.com/danger/danger/pull/1357
116
+ # Until that lands, I'm just using the underlying implementation of that method to get the diff for a file.
117
+ # Note that I might want to use a safe escape operator, `&.patch` and return gracefully if the patch cannot be found.
118
+ # However I'd be interested in why that ever happens, so for now going to proceed as is.
119
+ # (Note that better yet we'd have observability into these so I can just log under those circumstances rather than surfacing an error to the user,
120
+ # but we don't have that quite yet.)
121
+ patch_for_file = git.diff[deprecated_references_yml_file].patch
122
+ # This appears to be a known issue that patches require new lines at the end. It seems like this is an issue with Danger that
123
+ # it gives us a patch without a newline.
124
+ # https://stackoverflow.com/questions/18142870/git-error-fatal-corrupt-patch-at-line-36
125
+ patch_file << "#{patch_for_file}\n"
126
+ patch_file.rewind
127
+ # https://git-scm.com/docs/git-apply
128
+ _stdout, _stderr, _status = Open3.capture3("git apply --reverse #{patch_file.path}")
129
+ # https://www.rubyguides.com/2019/05/ruby-tempfile/
130
+ BasicReferenceOffense.from(deprecated_references_yml_file)
131
+ end
132
+ ensure
133
+ if deprecated_references_yml_file_copy
134
+ File.write(deprecated_references_yml_file, deprecated_references_yml_file_copy)
135
+ else
136
+ File.delete(deprecated_references_yml_file)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,104 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'danger'
5
+ require 'packwerk'
6
+ require 'sorbet-runtime'
7
+ require 'danger-packwerk/packwerk_wrapper'
8
+
9
+ module DangerPackwerk
10
+ # Note that Danger names the plugin (i.e. anything that inherits from `Danger::Plugin`) by taking the name of the class and gsubbing out "Danger"
11
+ # Therefore this plugin is simply called "packwerk"
12
+ class DangerPackwerk < Danger::Plugin
13
+ extend T::Sig
14
+
15
+ # We choose 15 because we want to err on the side of completeness and give users all of the information they need to help make their build pass,
16
+ # especially given all violations should fail the build anyways.
17
+ # We set a max (rather than unlimited) to avoid GitHub rate limiting and general spam if a PR does some sort of mass rename.
18
+ DEFAULT_MAX_COMMENTS = 15
19
+ OnFailure = T.type_alias { T.proc.params(offenses: T::Array[Packwerk::ReferenceOffense]).void }
20
+ DEFAULT_ON_FAILURE = T.let(->(offenses) {}, OnFailure)
21
+ OffensesFormatter = T.type_alias { T.proc.params(offenses: T::Array[Packwerk::ReferenceOffense]).returns(String) }
22
+ DEFAULT_OFFENSES_FORMATTER = T.let(->(offenses) { offenses.map(&:message).join("\n\n") }, OffensesFormatter)
23
+ DEFAULT_FAIL = false
24
+ DEFAULT_FAILURE_MESSAGE = 'Packwerk violations were detected! Please resolve them to unblock the build.'
25
+
26
+ sig do
27
+ params(
28
+ max_comments: Integer,
29
+ offenses_formatter: OffensesFormatter,
30
+ fail_build: T::Boolean,
31
+ failure_message: String,
32
+ on_failure: OnFailure
33
+ ).void
34
+ end
35
+ def check(
36
+ max_comments: DEFAULT_MAX_COMMENTS,
37
+ offenses_formatter: DEFAULT_OFFENSES_FORMATTER,
38
+ fail_build: DEFAULT_FAIL,
39
+ failure_message: DEFAULT_FAILURE_MESSAGE,
40
+ on_failure: DEFAULT_ON_FAILURE
41
+ )
42
+ # This is important because by default, Danger will leave a concantenated list of all its messages if it can't find a commentable place in the
43
+ # diff to leave its message. This is an especially bad UX because it will be a huge wall of text not connected to the source of the issue.
44
+ # Furthermore, dismissing these ensures that something like moving a file from pack to pack does not trigger the danger message. That is,
45
+ # the danger message will only be triggered by actual code that someone has actually written in their PR.
46
+ # Another example would be if someone changes the list of dependencies of a package (e.g. to resolve a cyclic dependency). This would not
47
+ # trigger the warning message, which is good, since we only want to trigger on new code.
48
+ github.dismiss_out_of_range_messages
49
+
50
+ # https://github.com/danger/danger/blob/eca19719d3e585fe1cc46bc5377f9aa955ebf609/lib/danger/danger_core/plugins/dangerfile_git_plugin.rb#L80
51
+ renamed_files_after = git.renamed_files.map { |f| f[:after] }
52
+ targeted_files = (git.modified_files + git.added_files + renamed_files_after).select do |f|
53
+ path = Pathname.new(f)
54
+
55
+ # We probably want to check the `include` key of `packwerk.yml`. By default, this value is "**/*.{rb,rake,erb}",
56
+ # so we hardcode this in for now. If this blocks a user, we can take that opportunity to read from `packwerk.yml`.
57
+ extension_is_targeted = ['.erb', '.rake', '.rb'].include?(path.extname)
58
+
59
+ # If a file has been modified via a rename, then `git.modified_files` will return an array that includes that file's *original* name.
60
+ # Packwerk will ignore input files that do not exist, and when the PR only contains renamed Ruby files, that means packwerk check works
61
+ # off of an empty list. It's default behavior in that case is to scan *all* files, which can lead to abnormally long run times.
62
+ # To avoid this, we gracefully return if there are no targeted files.
63
+ # To avoid false negatives, we also look at renamed files after (above)
64
+ file_exists = path.exist?
65
+
66
+ extension_is_targeted && file_exists
67
+ end
68
+
69
+ return if targeted_files.empty?
70
+
71
+ current_comment_count = 0
72
+
73
+ packwerk_reference_offenses = PackwerkWrapper.get_offenses_for_files(targeted_files.to_a).compact
74
+
75
+ # We group by the constant name, line number, and reference path. Any offenses with these same values should only differ on what type of violation
76
+ # they are (privacy or dependency). We put privacy and dependency violation messages in the same comment since they would occur on the same line.
77
+ packwerk_reference_offenses.group_by do |packwerk_reference_offense|
78
+ [
79
+ packwerk_reference_offense.reference.constant.name,
80
+ packwerk_reference_offense.location.line,
81
+ packwerk_reference_offense.reference.relative_path
82
+ ]
83
+ end.each do |_group, unique_packwerk_reference_offenses|
84
+ break if current_comment_count >= max_comments
85
+
86
+ current_comment_count += 1
87
+
88
+ reference_offense = T.must(unique_packwerk_reference_offenses.first)
89
+ line_number = reference_offense.location.line
90
+ referencing_file = reference_offense.reference.relative_path
91
+
92
+ message = offenses_formatter.call(unique_packwerk_reference_offenses)
93
+
94
+ markdown(message, file: referencing_file, line: line_number)
95
+ end
96
+
97
+ if current_comment_count > 0
98
+ fail(failure_message) if fail_build
99
+
100
+ on_failure.call(packwerk_reference_offenses)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+
3
+ module DangerPackwerk
4
+ # This class wraps packwerk to give us precisely what we want, which is the `Packwerk::ReferenceOffense` from a set of files.
5
+ # Note that statically packwerk returns `Packwerk::Offense` from running `bin/packwerk check`. The two types of `Packwerk::Offense` are
6
+ # `Packwerk::ReferenceOffense` and `Packwerk::Parsers::ParseResult`.`Packwerk::ReferenceOffense` inherits from `Packwerk::Offense`, and has more info than `Packwerk::Offense`.
7
+ # `Packwerk::Parsers::ParseResult` is returned when there is a file parsing issue. We ignore ParseResult types as it's likely that other tests would break along with this Danger check.
8
+ # and it is not the intent of this check to look for syntax errors in code.
9
+ #
10
+ # Also note that we would not need most of this class if there were two changes made to Packwerk:
11
+ # 1) It did not raise if no checkable files were found (I think it might make more sense to just return successfully rather than raise). This occurs if the
12
+ # input file list is excluded from the user's `exclude` list. In this case, check should return that no errors were found, since those files were not analyzed.
13
+ # 2) If the CLI gave a way to get offenses from files without this somewhat hacky way of passing in a formatter that stores the offenses.
14
+ class PackwerkWrapper
15
+ extend T::Sig
16
+
17
+ sig { params(files: T::Array[String]).returns(T::Array[Packwerk::ReferenceOffense]) }
18
+ def self.get_offenses_for_files(files)
19
+ formatter = OffensesAggregatorFormatter.new
20
+ # This is mostly copied from exe/packwerk within the packwerk gem, but we use our own formatters
21
+ ENV['RAILS_ENV'] = 'test'
22
+ style = Packwerk::OutputStyles::Coloured.new
23
+ cli = Packwerk::Cli.new(style: style, offenses_formatter: formatter)
24
+ cli.execute_command(['check', *files])
25
+ reference_offenses = formatter.aggregated_offenses.compact.select { |offense| offense.is_a?(Packwerk::ReferenceOffense) }
26
+ T.cast(reference_offenses, T::Array[Packwerk::ReferenceOffense])
27
+ rescue SystemExit => e
28
+ # Packwerk should probably exit positively here rather than raising an error -- there should be no
29
+ # errors if the user has excluded all files being checked.
30
+ if e.message == 'No files found or given. Specify files or check the include and exclude glob in the config file.'
31
+ []
32
+ else
33
+ raise
34
+ end
35
+ end
36
+
37
+ #
38
+ # This Packwerk formatter simply collects offenses. Ideally we could accomplish this by calling into public API of the CLI,
39
+ # but right now this is the only way to get the raw offenses out of packwerk.
40
+ #
41
+ class OffensesAggregatorFormatter
42
+ extend T::Sig
43
+ include Packwerk::OffensesFormatter
44
+
45
+ sig { returns(T::Array[Packwerk::Offense]) }
46
+ attr_reader :aggregated_offenses
47
+
48
+ sig { void }
49
+ def initialize
50
+ @aggregated_offenses = T.let([], T::Array[Packwerk::ReferenceOffense])
51
+ end
52
+
53
+ sig { override.params(offenses: T::Array[T.nilable(Packwerk::Offense)]).returns(String) }
54
+ def show_offenses(offenses)
55
+ @aggregated_offenses = T.unsafe(offenses)
56
+ ''
57
+ end
58
+
59
+ sig { override.params(offense_collection: Packwerk::OffenseCollection).returns(String) }
60
+ def show_stale_violations(offense_collection)
61
+ ''
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # typed: strict
2
+
3
+ module DangerPackwerk
4
+ module Private
5
+ class DefaultAddedOffensesFormatter
6
+ extend T::Sig
7
+
8
+ sig { params(violations: T::Array[BasicReferenceOffense]).returns(String) }
9
+ def self.format(violations)
10
+ violation = T.must(violations.first)
11
+ # We remove leading double colons as they feel like an implementation detail of packwerk.
12
+ constant_name = violation.class_name.gsub(/\A::/, '')
13
+ link_to_docs = '[the docs](https://github.com/Shopify/packwerk/blob/b647594f93c8922c038255a7aaca125d391a1fbf/docs/new_violation_flow_chart.pdf)'
14
+ disclaimer = "We noticed you ran `bin/packwerk update-deprecations`. Make sure to read through #{link_to_docs} for other ways to resolve. "
15
+ pluralized_violation = violations.count > 1 ? 'these violations' : 'this violation'
16
+ request_to_add_context = "Could you add some context as a reply here about why we needed to add #{pluralized_violation}?"
17
+
18
+ if violations.any?(&:dependency?) && violations.any?(&:privacy?)
19
+ <<~MESSAGE
20
+ Hi! It looks like the pack defining `#{constant_name}` considers this private API, and it's also not in the referencing pack's list of dependencies.
21
+ #{disclaimer}#{request_to_add_context}
22
+ MESSAGE
23
+ elsif violations.any?(&:dependency?)
24
+ <<~MESSAGE
25
+ Hi! It looks like the pack defining `#{constant_name}` is not in the referencing pack's list of dependencies.
26
+ #{disclaimer}#{request_to_add_context}
27
+ MESSAGE
28
+ else # violations.any?(&:privacy?)
29
+ <<~MESSAGE
30
+ Hi! It looks like the pack defining `#{constant_name}` considers this private API.
31
+ #{disclaimer}
32
+ #{request_to_add_context}
33
+ MESSAGE
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+
3
+ module DangerPackwerk
4
+ module Private
5
+ #
6
+ # The `Violation` and `DeprecatedReferences` classes come from Gusto's private `ParsePackwerk` gem.
7
+ # Until we decide to open source that gem, we inline these as a private implementation detail of `DangerPackwerk` for now.
8
+ #
9
+ class Violation < T::Struct
10
+ extend T::Sig
11
+
12
+ const :type, String
13
+ const :to_package_name, String
14
+ const :class_name, String
15
+ const :files, T::Array[String]
16
+
17
+ sig { returns(T::Boolean) }
18
+ def dependency?
19
+ type == 'dependency'
20
+ end
21
+
22
+ sig { returns(T::Boolean) }
23
+ def privacy?
24
+ type == 'privacy'
25
+ end
26
+ end
27
+
28
+ class DeprecatedReferences < T::Struct
29
+ extend T::Sig
30
+
31
+ const :pathname, Pathname
32
+ const :violations, T::Array[Violation]
33
+
34
+ sig { params(pathname: Pathname).returns(DeprecatedReferences) }
35
+ def self.from(pathname)
36
+ if pathname.exist?
37
+ deprecated_references_loaded_yml = YAML.load_file(pathname)
38
+
39
+ all_violations = []
40
+ deprecated_references_loaded_yml&.each_key do |to_package_name|
41
+ deprecated_references_per_package = deprecated_references_loaded_yml[to_package_name]
42
+ deprecated_references_per_package.each_key do |class_name|
43
+ symbol_usage = deprecated_references_per_package[class_name]
44
+ files = symbol_usage['files']
45
+ violations = symbol_usage['violations']
46
+ all_violations << Violation.new(type: 'dependency', to_package_name: to_package_name, class_name: class_name, files: files) if violations.include? 'dependency'
47
+
48
+ all_violations << Violation.new(type: 'privacy', to_package_name: to_package_name, class_name: class_name, files: files) if violations.include? 'privacy'
49
+ end
50
+ end
51
+
52
+ new(
53
+ pathname: pathname.cleanpath,
54
+ violations: all_violations
55
+ )
56
+ else
57
+ new(
58
+ pathname: pathname.cleanpath,
59
+ violations: []
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end