mudguard 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -0
  3. data/.mudguard.yml +6 -0
  4. data/.rubocop.yml +2 -0
  5. data/Gemfile.lock +25 -23
  6. data/Guardfile +1 -1
  7. data/README.md +62 -9
  8. data/exe/mudguard +5 -12
  9. data/lib/mudguard.rb +2 -17
  10. data/lib/mudguard/application/application.rb +33 -0
  11. data/lib/mudguard/domain/const_visitor.rb +20 -0
  12. data/lib/mudguard/domain/consts.rb +81 -0
  13. data/lib/mudguard/domain/dependencies.rb +47 -0
  14. data/lib/mudguard/domain/dependency.rb +28 -0
  15. data/lib/mudguard/domain/dependency_visitor.rb +32 -0
  16. data/lib/mudguard/{error.rb → domain/error.rb} +3 -1
  17. data/lib/mudguard/domain/policies.rb +49 -0
  18. data/lib/mudguard/domain/source.rb +79 -0
  19. data/lib/mudguard/domain/source_policies.rb +8 -0
  20. data/lib/mudguard/domain/source_processor.rb +92 -0
  21. data/lib/mudguard/domain/texts.rb +42 -0
  22. data/lib/mudguard/infrastructure/cli/controller.rb +78 -0
  23. data/lib/mudguard/infrastructure/cli/notification_adapter.rb +26 -0
  24. data/lib/mudguard/infrastructure/cli/view.rb +14 -0
  25. data/lib/mudguard/infrastructure/persistence/.mudguard.template.yml +6 -0
  26. data/lib/mudguard/infrastructure/persistence/policy_file.rb +46 -0
  27. data/lib/mudguard/infrastructure/persistence/project_repository.rb +31 -0
  28. data/lib/mudguard/infrastructure/persistence/ruby_files.rb +39 -0
  29. data/lib/mudguard/infrastructure/rake/task.rb +28 -0
  30. data/lib/mudguard/version.rb +1 -1
  31. data/mudguard.gemspec +2 -1
  32. metadata +40 -11
  33. data/lib/mudguard/policies.rb +0 -23
  34. data/lib/mudguard/policy_file.rb +0 -17
  35. data/lib/mudguard/ruby_analyser.rb +0 -74
  36. data/lib/mudguard/ruby_files.rb +0 -17
  37. data/lib/tasks/gem.rake +0 -15
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mudguard
4
+ module Domain
5
+ # A Dependency between Modules
6
+ class Dependency
7
+ attr_reader :location, :dependency
8
+
9
+ def initialize(location: nil, dependency:)
10
+ @location = location
11
+ @dependency = dependency
12
+ end
13
+
14
+ def inspect
15
+ "{#{@location}, #{@dependency}}"
16
+ end
17
+
18
+ def match(policy)
19
+ @dependency.match(policy)
20
+ end
21
+
22
+ def ==(other)
23
+ @location == other.instance_eval { @location } &&
24
+ @dependency == other.instance_eval { @dependency }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mudguard
4
+ module Domain
5
+ # Transforms AST-Nodes into Dependencies
6
+ class DependencyVisitor
7
+ def initialize(consts:)
8
+ @consts = consts
9
+ @dependencies = []
10
+ end
11
+
12
+ attr_reader :dependencies
13
+
14
+ def visit_dependency(location, const_name, module_name)
15
+ qualified_const_name = @consts.resolve(module_name, const_name)
16
+ return [] unless qualified_const_name&.include?("::")
17
+
18
+ dependency = if module_name.empty?
19
+ "->#{qualified_const_name}"
20
+ else
21
+ "#{module_name}->#{qualified_const_name}"
22
+ end
23
+
24
+ @dependencies << Dependency.new(location: location, dependency: dependency)
25
+ end
26
+
27
+ # rubocop:disable Naming/MethodParameterName
28
+ def visit_const_declaration(_, __, ___); end
29
+ # rubocop:enable Naming/MethodParameterName
30
+ end
31
+ end
32
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mudguard
4
- class Error < StandardError
4
+ module Domain
5
+ class Error < StandardError
6
+ end
5
7
  end
6
8
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dependencies"
4
+ require_relative "texts"
5
+ require_relative "consts"
6
+
7
+ module Mudguard
8
+ module Domain
9
+ # Contains the policies to be enforced
10
+ class Policies
11
+ include Texts
12
+
13
+ def initialize(source_policies: [])
14
+ @source_policies = source_policies
15
+ end
16
+
17
+ def check(notification)
18
+ result = analyse(:check, notification)
19
+
20
+ count = result[:sources_count]
21
+ violations = result[:analyser_count]
22
+
23
+ notification.add(nil, summary(count, violations))
24
+ violations.zero?
25
+ end
26
+
27
+ def print_allowed_dependencies(notification)
28
+ result = analyse(:print_allowed, notification)
29
+
30
+ count = result[:sources_count]
31
+ violations = result[:analyser_count]
32
+
33
+ notification.add(nil, dependency_summary(count, violations))
34
+ end
35
+
36
+ private
37
+
38
+ def analyse(method, notification)
39
+ consts = Consts.new(sources: @source_policies.map(&:source))
40
+ @source_policies.each_with_object(sources_count: 0, analyser_count: 0) do |sp, result|
41
+ analyser = Dependencies.new(policies: sp.policies, notification: notification)
42
+ dependencies = sp.source.find_mod_dependencies(consts)
43
+ result[:sources_count] += 1
44
+ result[:analyser_count] += analyser.send(method, dependencies)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parser/current"
4
+ require_relative "dependency"
5
+ require_relative "dependency_visitor"
6
+ require_relative "const_visitor"
7
+ require_relative "error"
8
+ require_relative "source_processor"
9
+
10
+ module Mudguard
11
+ module Domain
12
+ # Represents a Ruby source file
13
+ class Source
14
+ def initialize(location:, code_loader: -> { "" })
15
+ @code_loader = code_loader
16
+ @location = location
17
+ end
18
+
19
+ def ==(other)
20
+ @location == other.instance_eval { @location }
21
+ end
22
+
23
+ def hash
24
+ @location.hash
25
+ end
26
+
27
+ def eql?(other)
28
+ @location.eql?(other.instance_eval { @location })
29
+ end
30
+
31
+ def inspect
32
+ @location
33
+ end
34
+
35
+ def find_mod_dependencies(consts)
36
+ visitor = DependencyVisitor.new(consts: consts)
37
+ visit_ast(visitor)
38
+ visitor.dependencies
39
+ end
40
+
41
+ def find_consts
42
+ visitor = ConstVisitor.new
43
+ visit_ast(visitor)
44
+ visitor.consts
45
+ end
46
+
47
+ def location?(glob)
48
+ @location == glob
49
+ end
50
+
51
+ private
52
+
53
+ SYNTAX_ERROR = "error"
54
+
55
+ def ast
56
+ @ast ||= create_ast
57
+ end
58
+
59
+ def create_ast
60
+ begin
61
+ root = Parser::CurrentRuby.parse(code)
62
+ rescue Parser::SyntaxError
63
+ return SYNTAX_ERROR
64
+ end
65
+ root.nil? ? SYNTAX_ERROR : root
66
+ end
67
+
68
+ def code
69
+ @code ||= @code_loader.call
70
+ end
71
+
72
+ def visit_ast(visitor)
73
+ return if ast == SYNTAX_ERROR
74
+
75
+ SourceProcessor.new(location: @location).process(ast, visitor)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mudguard
4
+ module Domain
5
+ # Associates a source with it's policies
6
+ SourcePolicies = Struct.new(:source, :policies, keyword_init: true)
7
+ end
8
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mudguard
4
+ module Domain
5
+ # Processes the interesting parts of the Ast and forwards selected nodes to the visitors
6
+ class SourceProcessor
7
+ def initialize(location:)
8
+ @location = location
9
+ end
10
+
11
+ def process(node, visitor)
12
+ process_node(node, visitor, "")
13
+ end
14
+
15
+ private
16
+
17
+ def process_node(node, visitor, module_name) # rubocop:disable Metrics/MethodLength
18
+ case node
19
+ when type?(:module)
20
+ process_module(node, visitor, module_name)
21
+ when type?(:class)
22
+ process_module(node, visitor, module_name)
23
+ when type?(:const)
24
+ process_const(node, visitor, module_name)
25
+ when type?(:casgn)
26
+ process_const_assignment(node, visitor, module_name)
27
+ else
28
+ ignore_and_continue(node, visitor, module_name)
29
+ end
30
+ end
31
+
32
+ def process_const_assignment(node, visitor, module_name)
33
+ is_explicit, is_static, const_name = find_const_name(node.children)
34
+ return unless is_static
35
+
36
+ visitor.visit_const_declaration(describe_location(node), const_name,
37
+ is_explicit ? "" : module_name)
38
+ end
39
+
40
+ def process_const(node, visitor, module_name)
41
+ is_explicit, is_static, const_name = find_const_name(node.children)
42
+ return unless is_static
43
+
44
+ visitor.visit_dependency(describe_location(node), const_name,
45
+ is_explicit ? "" : module_name)
46
+ end
47
+
48
+ def describe_location(node)
49
+ "#{@location}:#{node.location.line}"
50
+ end
51
+
52
+ def ignore_and_continue(node, visitor, module_name)
53
+ return unless node.respond_to?(:children)
54
+
55
+ node.children.flat_map { |c| process_node(c, visitor, module_name) }
56
+ end
57
+
58
+ def process_module(node, visitor, module_name)
59
+ is_explicit, is_static, const_name = find_const_name(node.children[0].children)
60
+ return unless is_static
61
+
62
+ visitor.visit_const_declaration(describe_location(node), const_name, module_name)
63
+
64
+ module_name = "#{is_explicit ? '' : module_name}#{const_name}"
65
+ node.children.drop(1).reject(&:nil?).each do |child_node|
66
+ process_node(child_node, visitor, module_name)
67
+ end
68
+ end
69
+
70
+ def find_const_name(children)
71
+ return [false, nil] if children.nil? || children.empty?
72
+
73
+ first_child = children[0]
74
+ is_explicit, is_static = find_const_type(first_child)
75
+ first_child_children = first_child.respond_to?(:children) ? first_child.children : nil
76
+ _, __, module_name = find_const_name(first_child_children)
77
+ const_name = children[1].to_s
78
+ [is_explicit, is_static, "#{module_name}::#{const_name}"]
79
+ end
80
+
81
+ def find_const_type(first_child)
82
+ is_explicit = type?(:cbase).call(first_child)
83
+ is_static = is_explicit || first_child.nil? || type?(:const).call(first_child)
84
+ [is_explicit, is_static]
85
+ end
86
+
87
+ def type?(type)
88
+ ->(n) { n.respond_to?(:type) && n.type == type }
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mudguard
4
+ module Domain
5
+ # Builds texts to be displayed to a user
6
+ module Texts
7
+ def summary(file_count, violation_count)
8
+ files = pluralize("file", file_count)
9
+ "#{file_count} #{files} inspected, #{count(violation_count)}\
10
+ bad #{dependency(violation_count)} detected"
11
+ end
12
+
13
+ def dependency_not_allowed(dependency)
14
+ "#{dependency} not allowed"
15
+ end
16
+
17
+ def dependency_summary(file_count, dependency_count)
18
+ files = pluralize("file", file_count)
19
+ "#{file_count} #{files} inspected, #{count(dependency_count)}\
20
+ good #{dependency(dependency_count)} detected"
21
+ end
22
+
23
+ def dependency_allowed(dependency)
24
+ dependency.to_s
25
+ end
26
+
27
+ private
28
+
29
+ def dependency(count)
30
+ count <= 1 ? "dependency" : "dependencies"
31
+ end
32
+
33
+ def count(count)
34
+ count.zero? ? "no" : count.to_s
35
+ end
36
+
37
+ def pluralize(word, count)
38
+ count == 1 ? word : "#{word}s"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "mudguard/application/application"
5
+ require_relative "notification_adapter"
6
+
7
+ module Mudguard
8
+ module Infrastructure
9
+ module Cli
10
+ # Parses the cli arguments
11
+ class Controller
12
+ def initialize(view:) # rubocop:disable Metrics/MethodLength
13
+ @cmd = :analyse
14
+ @view = view
15
+ @display_opts = {
16
+ view: view,
17
+ compressed: false
18
+ }
19
+ @parser = ::OptionParser.new do |opts|
20
+ opts.banner = "Usage: mudguard [options] [directory]"
21
+ opts.on("-h", "--help", "Prints this help") do
22
+ @cmd = :help
23
+ end
24
+ opts.on("-p", "--print", "Prints all allowed dependencies") do
25
+ @cmd = :print_allowed
26
+ end
27
+ opts.on("-c", "--compressed", "Omits printing the same dependency more than once") do
28
+ @display_opts[:compressed] = true
29
+ end
30
+ end
31
+ end
32
+
33
+ def parse!(argv) # rubocop:disable Metrics/MethodLength
34
+ directories = @parser.parse!(argv)
35
+
36
+ case @cmd
37
+ when :print_allowed
38
+ print_allowed_dependencies(directories)
39
+ when :help
40
+ help
41
+ when :analyse
42
+ check_dependencies(directories)
43
+ else
44
+ raise StandardError, "unknown command #{@cmd}"
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def help
51
+ @view.print(@parser.to_s)
52
+ true
53
+ end
54
+
55
+ def check_dependencies(directories)
56
+ yield_directories(directories) do |directory, notification|
57
+ Mudguard::Application.check(directory, notification)
58
+ end
59
+ end
60
+
61
+ def print_allowed_dependencies(directories)
62
+ yield_directories(directories) do |directory, notification|
63
+ Mudguard::Application.print_allowed_dependencies(directory, notification)
64
+ true
65
+ end
66
+ end
67
+
68
+ def yield_directories(directories)
69
+ notification = NotificationAdapter.new(**@display_opts)
70
+ directories = [Dir.pwd] if directories.empty?
71
+ directories.all? do |directory|
72
+ yield directory, notification
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mudguard
4
+ module Infrastructure
5
+ module Cli
6
+ # Forwards Notification to the view for printing
7
+ class NotificationAdapter
8
+ def initialize(view:, compressed: false)
9
+ @view = view
10
+ @compressed = compressed
11
+ @printed_texts = Set.new
12
+ end
13
+
14
+ def add(location, text)
15
+ text = if location.nil? || @compressed
16
+ text
17
+ else
18
+ "#{location} #{text}"
19
+ end
20
+ @view.print(text) unless @printed_texts.include?(text)
21
+ @printed_texts << text
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end