yardcheck 0.0.1

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +214 -0
  5. data/.rubocop_todo.yml +69 -0
  6. data/.ruby-version +1 -0
  7. data/Gemfile +22 -0
  8. data/Guardfile +5 -0
  9. data/README.md +83 -0
  10. data/bin/yardcheck +6 -0
  11. data/lib/yardcheck.rb +26 -0
  12. data/lib/yardcheck/color.rb +27 -0
  13. data/lib/yardcheck/const.rb +39 -0
  14. data/lib/yardcheck/documentation.rb +29 -0
  15. data/lib/yardcheck/documentation/method_object.rb +111 -0
  16. data/lib/yardcheck/method_call.rb +38 -0
  17. data/lib/yardcheck/method_tracer.rb +86 -0
  18. data/lib/yardcheck/observation.rb +72 -0
  19. data/lib/yardcheck/proxy.rb +33 -0
  20. data/lib/yardcheck/runner.rb +93 -0
  21. data/lib/yardcheck/source_lines.rb +29 -0
  22. data/lib/yardcheck/spec_observer.rb +28 -0
  23. data/lib/yardcheck/test_runner.rb +27 -0
  24. data/lib/yardcheck/test_value.rb +82 -0
  25. data/lib/yardcheck/typedef.rb +122 -0
  26. data/lib/yardcheck/typedef/parser.rb +82 -0
  27. data/lib/yardcheck/version.rb +5 -0
  28. data/lib/yardcheck/violation.rb +156 -0
  29. data/lib/yardcheck/warning.rb +14 -0
  30. data/spec/integration/yardcheck_spec.rb +57 -0
  31. data/spec/spec_helper.rb +51 -0
  32. data/spec/unit/yardcheck/const_spec.rb +48 -0
  33. data/spec/unit/yardcheck/documentation_spec.rb +148 -0
  34. data/spec/unit/yardcheck/method_tracer_spec.rb +59 -0
  35. data/spec/unit/yardcheck/runner_spec.rb +183 -0
  36. data/spec/unit/yardcheck/test_value_spec.rb +25 -0
  37. data/spec/unit/yardcheck/typedef/parser_spec.rb +37 -0
  38. data/spec/unit/yardcheck/typedef_spec.rb +32 -0
  39. data/test_app/.rspec +1 -0
  40. data/test_app/Gemfile +7 -0
  41. data/test_app/Gemfile.lock +66 -0
  42. data/test_app/lib/test_app.rb +119 -0
  43. data/test_app/spec/test_app_spec.rb +49 -0
  44. data/yardcheck.gemspec +24 -0
  45. metadata +158 -0
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yardcheck
4
+ VERSION = '0.0.1'
5
+ end # Yardcheck
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yardcheck
4
+ class Violation
5
+ extend Color
6
+ include Color
7
+
8
+ def initialize(observation, test_locations = [observation.test_location])
9
+ @observation = observation
10
+ @test_locations = test_locations.sort.uniq
11
+ end
12
+
13
+ def offense
14
+ indented_source = indent(observation.source_code)
15
+ source = "\n#{CodeRay.encode(indented_source, :ruby, :terminal)}\n"
16
+
17
+ location_hint = indent(grey("source: #{observation.source_location}"))
18
+ test_lines = test_locations.map { |l| " - #{l}" }.join("\n")
19
+ tests_block = "tests:\n#{test_lines}"
20
+ test_hint = indent(grey(tests_block))
21
+
22
+ "#{explanation}\n\n#{location_hint}\n#{test_hint}\n#{source}\n"
23
+ end
24
+
25
+ def combine(other)
26
+ fail 'Cannot combine' unless combine_with?(other)
27
+
28
+ with_tests(other.test_locations)
29
+ end
30
+
31
+ def combination_identifier
32
+ combine_requirements.map(&method(:__send__))
33
+ end
34
+
35
+ attr_reader :test_locations
36
+
37
+ private
38
+
39
+ attr_reader :observation
40
+
41
+ def combine_with?(other)
42
+ combination_identifier == other.combination_identifier
43
+ end
44
+
45
+ def with_tests(other_test_locations)
46
+ self.class.new(observation, test_locations + other_test_locations)
47
+ end
48
+
49
+ def indent(string)
50
+ string.gsub(/^/, ' ')
51
+ end
52
+
53
+ def shorthand
54
+ observation.method_shorthand
55
+ end
56
+
57
+ def signature
58
+ expected_type.signature
59
+ end
60
+
61
+ def observed_type
62
+ observed_value.type
63
+ end
64
+
65
+ class Return < self
66
+ include Equalizer.new(:observation)
67
+
68
+ FORMAT =
69
+ "Expected #{blue('%<shorthand>s')} to return " \
70
+ "#{yellow('%<signature>s')} but observed " \
71
+ "#{red('%<observed_type>s')}"
72
+
73
+ def initialize(observation, test_locations = [observation.test_location])
74
+ super
75
+ end
76
+
77
+ def explanation
78
+ format(
79
+ FORMAT,
80
+ shorthand: shorthand,
81
+ signature: signature,
82
+ observed_type: observed_type
83
+ )
84
+ end
85
+
86
+ protected
87
+
88
+ def observed_type
89
+ observation.actual_return_type
90
+ end
91
+
92
+ private
93
+
94
+ def combine_requirements
95
+ %i[shorthand signature observed_type]
96
+ end
97
+
98
+ def expected_type
99
+ observation.documented_return_type
100
+ end
101
+ end # Return
102
+
103
+ class Param < self
104
+ include Equalizer.new(:name, :observation)
105
+
106
+ def initialize(name, observation, test_locations = [observation.test_location])
107
+ @name = name
108
+
109
+ super(observation, test_locations)
110
+ end
111
+
112
+ FORMAT =
113
+ "Expected #{blue('%<shorthand>s')} to " \
114
+ "receive #{yellow('%<signature>s')} for #{blue('%<name>s')} " \
115
+ "but observed #{red('%<test_value>s')}"
116
+
117
+ def explanation
118
+ format(
119
+ FORMAT,
120
+ shorthand: shorthand,
121
+ signature: signature,
122
+ name: name,
123
+ test_value: observed_type
124
+ )
125
+ end
126
+
127
+ private
128
+
129
+ attr_reader :name
130
+
131
+ def observed_type
132
+ test_value.type
133
+ end
134
+
135
+ def combine_requirements
136
+ %i[name shorthand signature observed_type]
137
+ end
138
+
139
+ def with_tests(other_test_locations)
140
+ self.class.new(
141
+ name,
142
+ observation,
143
+ test_locations + other_test_locations
144
+ )
145
+ end
146
+
147
+ def test_value
148
+ observation.observed_param(name)
149
+ end
150
+
151
+ def expected_type
152
+ observation.documented_param(name)
153
+ end
154
+ end # Param
155
+ end # Violation
156
+ end # Yardcheck
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yardcheck
4
+ class Warning
5
+ extend Color
6
+ include Concord.new(:method_object, :typedef)
7
+
8
+ MSG = "#{red('WARNING:')} Unabled to resolve #{yellow('%<typedef>s')} for %<location>s"
9
+
10
+ def message
11
+ format(MSG, typedef: typedef.signature, location: method_object.location_pointer)
12
+ end
13
+ end # Warning
14
+ end # Yardcheck
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ RSpec.describe 'test app integration' do
6
+ let(:report) { remove_color(run_yardcheck) }
7
+
8
+ def run_yardcheck
9
+ Bundler.with_clean_env do
10
+ Dir.chdir('test_app') do
11
+ system("bundle install --gemfile=#{File.join(Dir.pwd, 'Gemfile')}")
12
+ system('bundle exec yardcheck --namespace TestApp --include lib --require test_app')
13
+ end
14
+ end
15
+ end
16
+
17
+ def system(command)
18
+ output = nil
19
+
20
+ Open3.popen3(command) do |_stdin, _stdout, stderr|
21
+ output = stderr.read
22
+ end
23
+
24
+ output
25
+ end
26
+
27
+ def expect_report(report_substring)
28
+ expect(report).to match(a_string_including(report_substring))
29
+ end
30
+
31
+ def remove_color(string)
32
+ string.gsub(/\e\[(?:1\;)?\d+m/, '')
33
+ end
34
+
35
+ it 'generates a warning for invalid constant' do
36
+ expect_report('WARNING: Unabled to resolve "What" for lib/test_app.rb:37')
37
+ expect_report('WARNING: Unabled to resolve "Wow" for lib/test_app.rb:37')
38
+ expect_report('WARNING: Unabled to resolve :foo for lib/test_app.rb:109')
39
+ end
40
+
41
+ it 'reports expectations' do
42
+ aggregate_failures do
43
+ expect_report('Expected TestApp::Namespace#add to return String but observed Fixnum')
44
+ expect_report('Expected #<Class:TestApp::Namespace>#add to return String but observed Fixnum')
45
+ expect_report(
46
+ 'Expected TestApp::Namespace#documents_relative ' \
47
+ 'to return TestApp::Namespace::Child but observed String'
48
+ )
49
+ expect_report(
50
+ 'Expected TestApp::Namespace#improperly_tested_with_instance_double ' \
51
+ 'to receive String for value but observed Integer'
52
+ )
53
+ matches = report.scan(/^Expected .+ to return .+ but observed .+$/)
54
+ expect(matches.size).to be(3)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'bundler'
5
+ require 'timeout'
6
+
7
+ Bundler.with_clean_env { system('cd test_app && yard --no-cache --no-output > /dev/null') }
8
+
9
+ begin
10
+ require 'mutest'
11
+
12
+ module Mutest
13
+ class Selector
14
+ class Expression < self
15
+ def call(_subject)
16
+ integration.all_tests
17
+ end
18
+ end # Expression
19
+ end # Selector
20
+ end # Mutest
21
+ rescue LoadError
22
+ end
23
+
24
+ module YardcheckSpec
25
+ ROOT = Pathname.new(__dir__).parent
26
+ TEST_APP = ROOT.join('test_app')
27
+
28
+ Yardcheck::Documentation.load_yard
29
+ test_app_yardoc = TEST_APP.join('.yardoc')
30
+ YARD::Registry.load!(test_app_yardoc.to_s)
31
+ YARDOCS = YARD::Registry.all(:method)
32
+ end # YardcheckSpec
33
+
34
+ RSpec.configure do |config|
35
+ # Define metadata for all tests which live under spec/integration
36
+ config.define_derived_metadata(file_path: %r{\bspec/integration/}) do |metadata|
37
+ # Set the type of these tests as 'integration'
38
+ metadata[:type] = :integration
39
+
40
+ # Define metadata for mutant so it knows to never run these tests
41
+ metadata[:mutest] = false
42
+ end
43
+
44
+ config.around(file_path: %r{\bspec/unit/}) do |example|
45
+ Timeout.timeout(0.1, &example)
46
+ end
47
+ end
48
+
49
+ $LOAD_PATH.unshift(YardcheckSpec::TEST_APP.join('lib').to_s)
50
+
51
+ require 'test_app'
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Yardcheck::Const do
4
+ before do
5
+ stub_const('Foo', Module.new)
6
+
7
+ module Foo
8
+ module Bar
9
+ module Baz
10
+ end # Baz
11
+ end # Bar
12
+ end # Foo
13
+ end
14
+
15
+ it 'resolves top level constant' do
16
+ expect(described_class.resolve('Foo')).to eql(described_class.new(Foo))
17
+ end
18
+
19
+ it 'resolves namespaced constant with qualified top level parent' do
20
+ expect(described_class.resolve('Foo::Bar')).to eql(described_class.new(Foo::Bar))
21
+ end
22
+
23
+ it 'resolves nested constant from nested scope' do
24
+ expect(described_class.resolve('Bar', Foo)).to eql(described_class.new(Foo::Bar))
25
+ end
26
+
27
+ it 'resolves top parent constant from scope of nested constant' do
28
+ expect(described_class.resolve('Foo', Foo::Bar)).to eql(described_class.new(Foo))
29
+ end
30
+
31
+ it 'resolves nested constant form deeply nested scope' do
32
+ expect(described_class.resolve('Foo::Bar', Foo::Bar::Baz)).to eql(described_class.new(Foo::Bar))
33
+ end
34
+
35
+ it 'resolves top level stdlib constant from scope of nested constant' do
36
+ expect(described_class.resolve('String', Foo::Bar::Baz)).to eql(described_class.new(String))
37
+ end
38
+
39
+ it 'resolves the scope when given the scope name' do
40
+ expect(described_class.resolve('Bar', Foo::Bar)).to eql(described_class.new(Foo::Bar))
41
+ end
42
+
43
+ it 'returns an invalid const object when given an unresolveable reference' do
44
+ expect(described_class.resolve('What', Foo::Bar)).to eql(
45
+ described_class::Invalid.new(Foo::Bar, 'What')
46
+ )
47
+ end
48
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ require 'yard'
6
+
7
+ RSpec.describe Yardcheck::Documentation do
8
+ def yardocs_for(title)
9
+ selection = YardcheckSpec::YARDOCS.select { |doc| doc.title == title }
10
+ fail "Unable to find doc with title #{title}" if selection.empty?
11
+ selection
12
+ end
13
+
14
+ def doc_for(title)
15
+ described_class.new(yardocs_for(title))
16
+ end
17
+
18
+ def method_object(title)
19
+ doc_for(title).method_objects.first
20
+ end
21
+
22
+ def literal(const)
23
+ Yardcheck::Typedef::Literal.new(const)
24
+ end
25
+
26
+ def typedef(*members)
27
+ Yardcheck::Typedef.new(members)
28
+ end
29
+
30
+ def const(constant)
31
+ Yardcheck::Const.new(constant)
32
+ end
33
+
34
+ let(:namespace_add) { method_object('TestApp::Namespace#add') }
35
+ let(:undocumented) { method_object('TestApp::Namespace#undocumented') }
36
+ let(:invalid) { method_object('TestApp::Namespace#ignoring_invalid_types') }
37
+
38
+ it 'resolves constant' do
39
+ expect(namespace_add.namespace).to eql(TestApp::Namespace)
40
+ end
41
+
42
+ it 'resolves parameters' do
43
+ expect(namespace_add.params).to eql(
44
+ left: typedef(literal(const(Integer))),
45
+ right: typedef(literal(const(Integer)))
46
+ )
47
+ end
48
+
49
+ it 'resolves return value' do
50
+ expect(namespace_add.return_type).to eql(typedef(literal(const(String))))
51
+ end
52
+
53
+ it 'labels instance scope' do
54
+ expect(namespace_add.scope).to be(:instance)
55
+ end
56
+
57
+ it 'exposes the selector' do
58
+ expect(namespace_add.selector).to be(:add)
59
+ end
60
+
61
+ it 'handles documented returns without types' do
62
+ expect(method_object('TestApp::Namespace#return_tag_without_type').return_type)
63
+ .to eql(typedef)
64
+ end
65
+
66
+ it 'handles returns with a literal nil' do
67
+ expect(method_object('TestApp::Namespace#return_nil').return_type)
68
+ .to eql(typedef(literal(const(NilClass))))
69
+ end
70
+
71
+ it 'handles methods that return instance of the class' do
72
+ expect(method_object('TestApp::Namespace#return_self').return_type)
73
+ .to eql(typedef(literal(const(TestApp::Namespace))))
74
+ end
75
+
76
+ it 'supports [undefined]' do
77
+ expect(method_object('TestApp::Namespace#undefined_return').return_type)
78
+ .to eql(typedef(Yardcheck::Typedef::Undefined.new))
79
+ end
80
+
81
+ it 'supports [Boolean]' do
82
+ expect(method_object('TestApp::Namespace#bool_return').return_type)
83
+ .to eql(typedef(literal(const(TrueClass)), literal(const(FalseClass))))
84
+ end
85
+
86
+ it 'supports [Array<String>]' do
87
+ expect(method_object('TestApp::Namespace#array_return').return_type).to eql(
88
+ typedef(Yardcheck::Typedef::Collection.new(const(Array), [literal(const(String))]))
89
+ )
90
+ end
91
+
92
+ it 'supports multiple @return' do
93
+ expect(method_object('TestApp::Namespace#multiple_returns').return_type)
94
+ .to eql(typedef(literal(const(String)), literal(const(NilClass))))
95
+ end
96
+
97
+ it 'ignores documented params without names' do
98
+ expect(method_object('TestApp::Namespace#param_without_name').params).to eql({})
99
+ end
100
+
101
+ it 'ignores invalid constant resolve' do
102
+ expect(method_object('TestApp::Namespace#ignoring_invalid_types').params).to be_empty
103
+ end
104
+
105
+ it 'produces warnings for unresolvable params and returns' do
106
+ expect(invalid.warnings).to eql([
107
+ Yardcheck::Warning.new(
108
+ invalid,
109
+ Yardcheck::Typedef::Parser.new('TestApp::Namespace', %w[What]).parse
110
+ ),
111
+ Yardcheck::Warning.new(
112
+ invalid,
113
+ Yardcheck::Typedef::Parser.new('TestApp::Namespace', %w[Wow]).parse
114
+ )
115
+ ])
116
+ end
117
+
118
+ it 'does not provide a return type when documentation is invalid' do
119
+ expect(invalid.return_type).to be(nil)
120
+ end
121
+
122
+ it 'does not produce warnings for normal methods' do
123
+ aggregate_failures do
124
+ expect(method_object('TestApp::Namespace#add').warnings).to eql([])
125
+ expect(undocumented.warnings).to eql([])
126
+ end
127
+ end
128
+
129
+ it 'exposes source' do
130
+ yardoc = yardocs_for('TestApp::Namespace#return_self').first
131
+
132
+ allow(yardoc).to receive(:file).and_wrap_original do |method|
133
+ File.join('./test_app', method.call).to_s
134
+ end
135
+
136
+ method_object = described_class::MethodObject.new(yardoc)
137
+ expect(method_object.source).to eql(<<~RUBY.chomp)
138
+ # @return [Namespace]
139
+ def return_self
140
+ self
141
+ end
142
+ RUBY
143
+ end
144
+
145
+ it 'return type of undocumented' do
146
+ expect(undocumented.return_type).to be(nil)
147
+ end
148
+ end