scully 0.0.1 → 0.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +272 -9
- data/Rakefile +5 -1
- data/exe/scully +6 -0
- data/lib/scully/analyzer.rb +90 -0
- data/lib/scully/cli.rb +126 -0
- data/lib/scully/discovery.rb +53 -0
- data/lib/scully/frameworks/detector.rb +86 -0
- data/lib/scully/frameworks/helpers.rb +215 -0
- data/lib/scully/frameworks/minitest.rb +305 -0
- data/lib/scully/frameworks/rspec.rb +386 -0
- data/lib/scully/model.rb +48 -0
- data/lib/scully/render/report.rb +79 -0
- data/lib/scully/render/snippet.rb +92 -0
- data/lib/scully/render/terminal.rb +97 -0
- data/lib/scully/render/why.rb +146 -0
- data/lib/scully/rule.rb +85 -0
- data/lib/scully/rules/no_assertion.rb +51 -0
- data/lib/scully/rules/parse.rb +34 -0
- data/lib/scully/rules/tautology.rb +101 -0
- data/lib/scully/source_file.rb +53 -0
- data/lib/scully/version.rb +1 -1
- data/lib/scully.rb +18 -1
- metadata +39 -6
- data/sig/test_scully.rbs +0 -3
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Scully
|
|
6
|
+
module Frameworks
|
|
7
|
+
# Turns an RSpec file into TestCases: examples inside describe/context/shared groups, with
|
|
8
|
+
# their status, the setup of every enclosing group, and the assertions in their bodies.
|
|
9
|
+
class RSpec
|
|
10
|
+
GROUPS = %i[describe context feature example_group shared_examples shared_examples_for shared_context
|
|
11
|
+
fdescribe fcontext ffeature].freeze
|
|
12
|
+
SKIPPED_GROUPS = %i[xdescribe xcontext xfeature].freeze
|
|
13
|
+
EXAMPLES = %i[it specify example scenario fit fspecify fexample fscenario].freeze
|
|
14
|
+
SKIPPED_EXAMPLES = %i[xit xspecify xexample xscenario].freeze
|
|
15
|
+
# `pending "..." do` and `skip "..." do` at group level declare examples; inside an
|
|
16
|
+
# example, `pending` and `skip` calls mark it.
|
|
17
|
+
MARKERS = { pending: :pending, skip: :skipped }.freeze
|
|
18
|
+
HOOKS = { before: :before, prepend_before: :before, append_before: :before, around: :before,
|
|
19
|
+
after: :teardown, prepend_after: :teardown, append_after: :teardown }.freeze
|
|
20
|
+
LETS = { let: :let, let!: :let, subject: :subject, subject!: :subject }.freeze
|
|
21
|
+
SETUP = HOOKS.merge(LETS).freeze
|
|
22
|
+
|
|
23
|
+
# The names, setup, bindings and setup doubles visible inside a group, and the status a
|
|
24
|
+
# skipped or pending group gives its examples.
|
|
25
|
+
Context = Data.define(:names, :setup, :bindings, :doubles, :marked)
|
|
26
|
+
# `is_expected` and a bare `should` check the subject.
|
|
27
|
+
SUBJECT_READERS = %i[is_expected should should_not].freeze
|
|
28
|
+
|
|
29
|
+
include Syntax
|
|
30
|
+
|
|
31
|
+
def self.call(source) = new(source).test_cases
|
|
32
|
+
|
|
33
|
+
def initialize(source)
|
|
34
|
+
@source = source
|
|
35
|
+
@helpers = Helpers.new(source.result.value, assertion: ->(call) { !Expectations.fields(call).nil? })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_cases
|
|
39
|
+
@tests = []
|
|
40
|
+
walk(@source.result.value.statements,
|
|
41
|
+
Context.new(names: [], setup: [], bindings: {}, doubles: [], marked: nil))
|
|
42
|
+
@tests
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def walk(node, context)
|
|
48
|
+
statements(node).each { statement(it, context) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def statement(node, context)
|
|
52
|
+
case node
|
|
53
|
+
when Prism::ModuleNode, Prism::ClassNode then walk(node.body, context)
|
|
54
|
+
when Prism::ItLocalVariableReadNode then bare_example(node, context)
|
|
55
|
+
when Prism::CallNode then call_statement(node, context)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Examples generated in loops (`%w[a b].each do ... it ... end`) belong to the group.
|
|
60
|
+
def call_statement(call, context)
|
|
61
|
+
if group?(call) then walk(call.block.body, group_context(call, context))
|
|
62
|
+
elsif example?(call) then example(call, context)
|
|
63
|
+
elsif call.block.is_a?(Prism::BlockNode) && !setup_call?(call) then walk(call.block.body, context)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def group?(call)
|
|
68
|
+
(call.receiver.nil? || Detector.rspec_receiver?(call.receiver)) && call.block.is_a?(Prism::BlockNode) &&
|
|
69
|
+
(GROUPS.include?(call.name) || SKIPPED_GROUPS.include?(call.name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def example?(call)
|
|
73
|
+
return false unless call.receiver.nil?
|
|
74
|
+
return true if EXAMPLES.include?(call.name) || SKIPPED_EXAMPLES.include?(call.name)
|
|
75
|
+
|
|
76
|
+
MARKERS.key?(call.name) && !(call.block || description(call)).nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def setup_call?(call) = call.receiver.nil? && SETUP.key?(call.name)
|
|
80
|
+
|
|
81
|
+
def group_context(call, context)
|
|
82
|
+
own = own_setup(call.block.body)
|
|
83
|
+
context.with(names: [*context.names, *description(call)], marked: context.marked || Status.group(call),
|
|
84
|
+
**setup_from(own, context))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def setup_from(own, context)
|
|
88
|
+
{ setup: context.setup + own.map { setup_step(it) },
|
|
89
|
+
bindings: context.bindings.merge(*own.map { Scope.bindings(it) }),
|
|
90
|
+
doubles: context.doubles + own.flat_map { Scope.doubles(it, Doubles.new(@source, :setup)) } }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def own_setup(body) = statements(body).select { it.is_a?(Prism::CallNode) && setup_call?(it) }
|
|
94
|
+
|
|
95
|
+
def setup_step(call) = SetupStep.new(kind: SETUP[call.name], span: span(call.location))
|
|
96
|
+
|
|
97
|
+
def example(call, context)
|
|
98
|
+
add(context, name: name(context, description(call), call), definition: definition(call),
|
|
99
|
+
extent: call.location, status: Status.of(call, context), **contents(call.block&.body, context))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Bindings, assertions, helper calls and doubles of an example body.
|
|
103
|
+
def contents(body, context)
|
|
104
|
+
bindings = context.bindings.merge(assignments(body).to_h)
|
|
105
|
+
assertions, helper_calls = inventory(context).scan(body)
|
|
106
|
+
{ bindings:, helper_calls:, assertions: assertions.map { Scope.read_subject(it, bindings) },
|
|
107
|
+
doubles: context.doubles + Stubs.scan(body, Doubles.new(@source, :body)) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A bare `it` inside a group is a pending example; Ruby reads it as the block's `it` parameter.
|
|
111
|
+
def bare_example(node, context)
|
|
112
|
+
add(context, name: name(context, nil, node), definition: node.location, extent: node.location,
|
|
113
|
+
status: :pending, assertions: [], helper_calls: [], bindings: context.bindings,
|
|
114
|
+
doubles: context.doubles)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def add(context, definition:, extent:, **fields)
|
|
118
|
+
@tests << TestCase.new(span: span(definition), extent: span(extent), framework: :rspec, setup: context.setup,
|
|
119
|
+
**fields)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def name(context, description, node)
|
|
123
|
+
[*context.names, description || "example at line #{node.location.start_line}"].join(' ')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def span(location) = @source.span(location)
|
|
127
|
+
|
|
128
|
+
def inventory(context)
|
|
129
|
+
Inventory.new(source: @source, helpers: @helpers, assertion: Expectations.method(:fields),
|
|
130
|
+
ignored: ->(name) { Expectations::VOCABULARY.include?(name) || context.bindings.include?(name) })
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Decides whether an example is active, pending or skipped.
|
|
134
|
+
module Status
|
|
135
|
+
module_function
|
|
136
|
+
|
|
137
|
+
def of(call, context)
|
|
138
|
+
return context.marked if context.marked
|
|
139
|
+
return :skipped if SKIPPED_EXAMPLES.include?(call.name)
|
|
140
|
+
|
|
141
|
+
MARKERS[call.name] || metadata(call) || (call.block ? body(call.block.body) || :active : :pending)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A skipped or pending group marks every example inside it the same way.
|
|
145
|
+
def group(call) = SKIPPED_GROUPS.include?(call.name) ? :skipped : metadata(call)
|
|
146
|
+
|
|
147
|
+
# `:skip`, `:pending`, `skip: ...` or `pending: ...` metadata; a false or nil value does not count.
|
|
148
|
+
def metadata(call)
|
|
149
|
+
(call.arguments&.arguments || []).each do |argument|
|
|
150
|
+
status = case argument
|
|
151
|
+
when Prism::SymbolNode then MARKERS[argument.unescaped.to_sym]
|
|
152
|
+
when Prism::KeywordHashNode then keyword(argument)
|
|
153
|
+
end
|
|
154
|
+
return status if status
|
|
155
|
+
end
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def keyword(hash)
|
|
160
|
+
hash.elements.each do |element|
|
|
161
|
+
next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
|
|
162
|
+
next if element.value.is_a?(Prism::FalseNode) || element.value.is_a?(Prism::NilNode)
|
|
163
|
+
|
|
164
|
+
status = MARKERS[element.key.unescaped.to_sym]
|
|
165
|
+
return status if status
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A `skip` or `pending` call anywhere in the body marks the example.
|
|
171
|
+
def body(node)
|
|
172
|
+
node&.compact_child_nodes&.each do |child|
|
|
173
|
+
next if child.is_a?(Prism::DefNode)
|
|
174
|
+
|
|
175
|
+
status = marker(child) || body(child)
|
|
176
|
+
return status if status
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def marker(node) = node.is_a?(Prism::CallNode) && node.receiver.nil? ? MARKERS[node.name] : nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# What a group's setup gives its examples: bindings from `let`/`subject` and doubles.
|
|
185
|
+
module Scope
|
|
186
|
+
module_function
|
|
187
|
+
|
|
188
|
+
# `let(:tax) { ... }` binds `tax`; a named subject also binds `subject`.
|
|
189
|
+
def bindings(call)
|
|
190
|
+
return {} unless LETS.key?(call.name)
|
|
191
|
+
|
|
192
|
+
value = Syntax.block_value(call)
|
|
193
|
+
name = Syntax.binding_name(call)
|
|
194
|
+
call.name.start_with?('subject') ? { name => value, subject: value } : { name => value }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Doubles from a `let`/`subject` value, and stubs in `before` hooks; teardown hooks run
|
|
198
|
+
# too late to matter.
|
|
199
|
+
def doubles(call, doubles)
|
|
200
|
+
if LETS.key?(call.name)
|
|
201
|
+
Stubs.attributes(Syntax.block_value(call), Syntax.binding_name(call), doubles) || []
|
|
202
|
+
elsif HOOKS[call.name] == :before
|
|
203
|
+
Stubs.scan(call.block&.body, doubles)
|
|
204
|
+
else
|
|
205
|
+
[]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# `is_expected.to eq(x)` checks the value `subject` is bound to.
|
|
210
|
+
def read_subject(assertion, bindings)
|
|
211
|
+
reader = assertion.subject
|
|
212
|
+
return assertion unless reader.is_a?(Prism::CallNode) && reader.receiver.nil? &&
|
|
213
|
+
SUBJECT_READERS.include?(reader.name)
|
|
214
|
+
|
|
215
|
+
assertion.with(subject: bindings[:subject])
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Finds RSpec stubs (`allow(r).to receive(:m).and_return(v)`, `receive_messages`), message
|
|
220
|
+
# expectations that also return a value, and doubles created with preset attributes.
|
|
221
|
+
module Stubs
|
|
222
|
+
# Stub target => whether it is a stub source. `expect(...).to receive` can fail on its own.
|
|
223
|
+
TARGETS = { allow: true, allow_any_instance_of: true, expect: false, expect_any_instance_of: false }.freeze
|
|
224
|
+
DOUBLES = %i[double instance_double class_double object_double spy instance_spy class_spy object_spy].freeze
|
|
225
|
+
|
|
226
|
+
module_function
|
|
227
|
+
|
|
228
|
+
# The TestDoubles in +node+, in source order. +doubles+ is a Frameworks::Doubles.
|
|
229
|
+
def scan(node, doubles, found = [])
|
|
230
|
+
node&.compact_child_nodes&.each do |child|
|
|
231
|
+
next if child.is_a?(Prism::DefNode)
|
|
232
|
+
|
|
233
|
+
matched = stub(child, doubles) || local_double(child, doubles)
|
|
234
|
+
matched ? found.concat(matched) : scan(child, doubles, found)
|
|
235
|
+
end
|
|
236
|
+
found
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# `allow(r).to receive(:m).and_return(v)`: the matcher chain is inside-out, so the
|
|
240
|
+
# innermost call names the method and an `and_return` around it gives the value.
|
|
241
|
+
def stub(node, doubles)
|
|
242
|
+
target = target(node) or return
|
|
243
|
+
receiver = Syntax.arguments(target).first or return
|
|
244
|
+
|
|
245
|
+
matcher = Syntax.chain(Syntax.arguments(node).first)
|
|
246
|
+
stubbed(matcher, node.location, doubles, receiver:, stub_source: TARGETS[target.name])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The `allow(r)` or `expect(r)` a `.to` call is made on, or nil.
|
|
250
|
+
def target(node)
|
|
251
|
+
target = node.receiver if node.is_a?(Prism::CallNode) && node.name == :to
|
|
252
|
+
target if target.is_a?(Prism::CallNode) && target.receiver.nil? && TARGETS.key?(target.name)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def stubbed(chain, location, doubles, **fields)
|
|
256
|
+
case chain.last&.name
|
|
257
|
+
when :receive
|
|
258
|
+
name = Syntax.stubbed_name(chain.last) or return
|
|
259
|
+
[doubles.build(location, method_name: name, returns: returned(chain), **fields)]
|
|
260
|
+
when :receive_messages
|
|
261
|
+
Syntax.keyword_pairs(chain.last).map do |name, value|
|
|
262
|
+
doubles.build(location, method_name: name, returns: value, **fields)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The single value given to `and_return`; several values are a sequence, not one value.
|
|
268
|
+
def returned(chain)
|
|
269
|
+
and_return = chain.find { it.name == :and_return } or return
|
|
270
|
+
values = Syntax.arguments(and_return)
|
|
271
|
+
values.first if values.size == 1
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# `tax = double(rate: 0.2)`
|
|
275
|
+
def local_double(node, doubles)
|
|
276
|
+
attributes(node.value, node.name, doubles) if node.is_a?(Prism::LocalVariableWriteNode)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The preset attributes of a double bound to +name+, one TestDouble each; nil when +value+
|
|
280
|
+
# does not create a double.
|
|
281
|
+
def attributes(value, name, doubles)
|
|
282
|
+
return unless value.is_a?(Prism::CallNode) && value.receiver.nil? && DOUBLES.include?(value.name)
|
|
283
|
+
|
|
284
|
+
receiver = Syntax.identifier(name)
|
|
285
|
+
Syntax.keyword_pairs(value).map do |method_name, returns|
|
|
286
|
+
doubles.build(value.location, receiver:, method_name:, returns:)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Recognises RSpec expectations and classifies their matchers.
|
|
292
|
+
module Expectations
|
|
293
|
+
TARGETS = %i[expect is_expected are_expected expect_any_instance_of].freeze
|
|
294
|
+
RUNNERS = %i[to not_to to_not].freeze
|
|
295
|
+
SHOULD = %i[should should_not].freeze
|
|
296
|
+
COMPARISONS = { eq: :equality, eql: :equality, equal: :identity }.freeze
|
|
297
|
+
RAISE_CHECKS = %i[raise_error raise_exception].freeze
|
|
298
|
+
MESSAGE_EXPECTATIONS = %i[receive receive_messages receive_message_chain have_received].freeze
|
|
299
|
+
# Minitest assertions that rspec-rails mixes into examples, and Capybara's
|
|
300
|
+
# `page.assert_selector`-style matchers, with or without a receiver.
|
|
301
|
+
MINITEST = /\A(assert|refute)(_|\z)/
|
|
302
|
+
|
|
303
|
+
# Receiverless calls that are RSpec or Ruby vocabulary, never assertion helpers.
|
|
304
|
+
VOCABULARY = Set[
|
|
305
|
+
*GROUPS, *SKIPPED_GROUPS, *EXAMPLES, *SKIPPED_EXAMPLES, *MARKERS.keys, *SETUP.keys, *TARGETS,
|
|
306
|
+
*MESSAGE_EXPECTATIONS, :allow, :allow_any_instance_of, :double, :instance_double, :class_double,
|
|
307
|
+
:object_double, :spy, :instance_spy, :class_spy, :object_spy, :described_class, :aggregate_failures,
|
|
308
|
+
:anything, :any_args, :no_args, :hash_including, :array_including, :instance_of, :kind_of,
|
|
309
|
+
:include_examples, :include_context, :it_behaves_like, :it_should_behave_like,
|
|
310
|
+
:puts, :p, :pp, :print, :raise, :fail, :lambda, :proc, :loop, :sleep, :require, :require_relative, :format
|
|
311
|
+
].freeze
|
|
312
|
+
|
|
313
|
+
module_function
|
|
314
|
+
|
|
315
|
+
# Assertion fields for an expectation, or nil when +call+ is not one.
|
|
316
|
+
def fields(call)
|
|
317
|
+
if runner?(call)
|
|
318
|
+
matcher(argument(call), subject(call.receiver), negated: call.name != :to)
|
|
319
|
+
elsif SHOULD.include?(call.name) then should(call)
|
|
320
|
+
elsif should_operator?(call)
|
|
321
|
+
{ kind: :comparison, comparison: :equality, subject: call.receiver.receiver, expected: argument(call) }
|
|
322
|
+
elsif MINITEST.match?(call.name) then minitest(call)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Receiverless `assert_equal expected, actual` reads as in Minitest; receiver forms
|
|
327
|
+
# (`page.assert_selector`) are matchers that check nothing comparable.
|
|
328
|
+
def minitest(call)
|
|
329
|
+
return { kind: :other, comparison: nil, subject: nil, expected: nil } if call.receiver
|
|
330
|
+
|
|
331
|
+
Frameworks::Minitest::Assertions.assertion(call)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# `x.should eq(1)`; a bare `should` stands in for the subject until the normalizer resolves it.
|
|
335
|
+
def should(call) = matcher(argument(call), call.receiver || call, negated: call.name == :should_not)
|
|
336
|
+
|
|
337
|
+
# `expect(x).to`, `is_expected.not_to`, `expect { }.to`
|
|
338
|
+
def runner?(call)
|
|
339
|
+
target = call.receiver
|
|
340
|
+
RUNNERS.include?(call.name) && target.is_a?(Prism::CallNode) && target.receiver.nil? &&
|
|
341
|
+
TARGETS.include?(target.name)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# `expect(x)` checks x. `is_expected` stands in for the subject until the normalizer
|
|
345
|
+
# resolves it; block expectations have no subject expression.
|
|
346
|
+
def subject(target)
|
|
347
|
+
case target.name
|
|
348
|
+
when :expect then argument(target)
|
|
349
|
+
when :is_expected then target
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# `x.should == 1`
|
|
354
|
+
def should_operator?(call)
|
|
355
|
+
call.name == :== && call.receiver.is_a?(Prism::CallNode) && call.receiver.name == :should
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def argument(call) = call.arguments&.arguments&.first
|
|
359
|
+
|
|
360
|
+
# Matchers chain inside-out (`receive(:m).with(1)`): the innermost call names the matcher.
|
|
361
|
+
def matcher(node, subject, negated:)
|
|
362
|
+
node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode)
|
|
363
|
+
kind, comparison = classify(node.is_a?(Prism::CallNode) ? node : nil)
|
|
364
|
+
comparison = nil if negated
|
|
365
|
+
{ kind:, comparison:, subject:, expected: comparison && argument(node) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def classify(matcher)
|
|
369
|
+
name = matcher&.name
|
|
370
|
+
return [:comparison, COMPARISONS[name]] if COMPARISONS.key?(name)
|
|
371
|
+
return matcher.arguments ? %i[comparison identity] : [:predicate, nil] if name == :be
|
|
372
|
+
|
|
373
|
+
[kind(name), nil]
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def kind(name)
|
|
377
|
+
if RAISE_CHECKS.include?(name) then :raise_check
|
|
378
|
+
elsif MESSAGE_EXPECTATIONS.include?(name) then :message_expectation
|
|
379
|
+
elsif name&.start_with?('be_', 'have_') then :predicate
|
|
380
|
+
else :other
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
data/lib/scully/model.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scully
|
|
4
|
+
# A region of a source file. Lines and columns are 1-based; columns count characters.
|
|
5
|
+
# The end column is exclusive, so a zero-length span has start_column == end_column.
|
|
6
|
+
Span = Data.define(:path, :start_line, :start_column, :end_line, :end_column) do
|
|
7
|
+
def sort_key = [path.b, start_line, start_column]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# A span with the text printed next to its marker.
|
|
11
|
+
Label = Data.define(:span, :text)
|
|
12
|
+
|
|
13
|
+
# A setup or teardown step from an enclosing group: :before, :setup, :let, :subject, :teardown.
|
|
14
|
+
SetupStep = Data.define(:kind, :span)
|
|
15
|
+
|
|
16
|
+
# An assertion found in a test body. +subject+ and +expected+ are opaque expressions.
|
|
17
|
+
Assertion = Data.define(:span, :kind, :comparison, :subject, :expected)
|
|
18
|
+
|
|
19
|
+
# A stub, double, or message expectation.
|
|
20
|
+
TestDouble = Data.define(:span, :receiver, :method_name, :returns, :origin, :scope, :stub_source)
|
|
21
|
+
|
|
22
|
+
# A call in a test body that may be an assertion helper.
|
|
23
|
+
HelperCall = Data.define(:name, :span, :resolution)
|
|
24
|
+
|
|
25
|
+
# One test, normalized so that rules never depend on the framework. +span+ is the definition
|
|
26
|
+
# (`it "..."`, `def test_x`) that findings point at; +extent+ is the whole test, through its `end`.
|
|
27
|
+
TestCase = Data.define(
|
|
28
|
+
:name, :span, :extent, :framework, :status, :setup, :assertions, :doubles, :bindings, :helper_calls
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Severities a rule may declare, in report order.
|
|
32
|
+
SEVERITIES = %i[error warning].freeze
|
|
33
|
+
|
|
34
|
+
# A problem reported by a rule.
|
|
35
|
+
Finding = Data.define(:rule_id, :severity, :primary, :secondary, :test_name, :facts, :evidence) do
|
|
36
|
+
def sort_key = [SEVERITIES.index(severity), *primary.span.sort_key, rule_id.to_s]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A syntax error reported by the parser.
|
|
40
|
+
ParseError = Data.define(:span, :message)
|
|
41
|
+
|
|
42
|
+
# The analysis of one file: status is :complete, :incomplete or :skipped.
|
|
43
|
+
FileReport = Data.define(:path, :framework, :status, :skip_reason, :parse_errors, :test_cases)
|
|
44
|
+
|
|
45
|
+
# The result of a run. +counts+ has the keys
|
|
46
|
+
# :files, :incomplete, :skipped, :tests, :not_analyzed, :errors, :warnings.
|
|
47
|
+
RunReport = Data.define(:files, :findings, :counts, :exit_status)
|
|
48
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scully
|
|
4
|
+
module Render
|
|
5
|
+
# Renders a RunReport as the plain-text report printed by `scully [PATH ...]`.
|
|
6
|
+
class Report
|
|
7
|
+
GROUPS = { error: 'errors', warning: 'warnings' }.freeze
|
|
8
|
+
EVIDENCE = 'evidence: static analysis only; coverage and mutation testing not run'
|
|
9
|
+
FOLLOW_UP = 'Use `scully why <location>` for more info about each finding:'
|
|
10
|
+
COMMENT_GAP = ' '
|
|
11
|
+
|
|
12
|
+
# +messages+ maps rule ids to their header messages; +sources+ maps paths to objects
|
|
13
|
+
# that respond to line(number); +terminal+ adds colour when it is on.
|
|
14
|
+
def self.render(report, messages:, sources:, terminal: Terminal::PLAIN)
|
|
15
|
+
new(report, messages, sources, terminal).render
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One `scully why <path>:<line> # <rule_id>` line per finding, commands padded to the longest.
|
|
19
|
+
def self.commands(findings)
|
|
20
|
+
commands = findings.map do
|
|
21
|
+
span = it.primary.span
|
|
22
|
+
["scully why #{span.path}:#{span.start_line}", it.rule_id]
|
|
23
|
+
end
|
|
24
|
+
width = commands.map { it.first.size }.max
|
|
25
|
+
commands.map { |command, rule_id| "#{command.ljust(width)}#{COMMENT_GAP}# #{rule_id}" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `skipped: <path>: <reason>` for a skipped FileReport, ending in a newline.
|
|
29
|
+
def self.skip_line(file) = "skipped: #{file.path}: #{file.skip_reason}\n"
|
|
30
|
+
|
|
31
|
+
def initialize(report, messages, sources, terminal)
|
|
32
|
+
@report = report
|
|
33
|
+
@terminal = terminal
|
|
34
|
+
@messages = messages
|
|
35
|
+
@sources = sources
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render
|
|
39
|
+
sections = [*finding_groups, skipped_group].compact
|
|
40
|
+
text = sections.map { "#{it}\n" }.join
|
|
41
|
+
text << summary << "\n" << EVIDENCE << "\n"
|
|
42
|
+
text << follow_up unless @report.findings.empty?
|
|
43
|
+
text
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def finding_groups
|
|
49
|
+
GROUPS.filter_map do |severity, title|
|
|
50
|
+
findings = @report.findings.select { it.severity == severity }
|
|
51
|
+
next if findings.empty?
|
|
52
|
+
|
|
53
|
+
"#{title} (#{findings.size})\n\n#{findings.map { block(it) }.join("\n")}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def block(finding)
|
|
58
|
+
Snippet.render(finding, message: @messages.fetch(finding.rule_id),
|
|
59
|
+
source: @sources.fetch(finding.primary.span.path), terminal: @terminal)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def skipped_group
|
|
63
|
+
skipped = @report.files.select { it.status == :skipped }
|
|
64
|
+
return if skipped.empty?
|
|
65
|
+
|
|
66
|
+
"skipped (#{skipped.size})\n\n#{skipped.map { self.class.skip_line(it) }.join}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def summary
|
|
70
|
+
counts = @report.counts
|
|
71
|
+
not_analyzed = counts[:not_analyzed].zero? ? '' : " (#{counts[:not_analyzed]} not analyzed)"
|
|
72
|
+
"summary: #{counts[:files]} files (#{counts[:incomplete]} incomplete, #{counts[:skipped]} skipped), " \
|
|
73
|
+
"#{counts[:tests]} tests#{not_analyzed}, #{counts[:errors]} errors, #{counts[:warnings]} warnings"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def follow_up = "\n#{FOLLOW_UP}\n\n#{self.class.commands(@report.findings).map { "#{it}\n" }.join}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scully
|
|
4
|
+
module Render
|
|
5
|
+
# Renders a finding as a block: header, location, and the source lines its labels point at.
|
|
6
|
+
#
|
|
7
|
+
# error[tautology]: assertion checks a value the test stubbed
|
|
8
|
+
# --> spec/tautology_spec.rb:7:5
|
|
9
|
+
# |
|
|
10
|
+
# 2 | let(:tax) { double(rate: 0.2) }
|
|
11
|
+
# | ----------------- stubbed in setup to return `0.2`
|
|
12
|
+
# ...
|
|
13
|
+
# 7 | expect(tax.rate).to eq(0.2)
|
|
14
|
+
# | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ compares the stub with its own return value
|
|
15
|
+
class Snippet
|
|
16
|
+
TAB = ' '
|
|
17
|
+
PRIMARY = '^'
|
|
18
|
+
SECONDARY = '-'
|
|
19
|
+
INCONCLUSIVE = ' (incomplete file: inconclusive)'
|
|
20
|
+
|
|
21
|
+
# +source+ responds to line(number) and returns the line's text without its terminator.
|
|
22
|
+
# +terminal+ colours the severity word and markers when colour is on.
|
|
23
|
+
# Returns the block as a String ending in a newline.
|
|
24
|
+
def self.render(finding, message:, source:, terminal: Terminal::PLAIN)
|
|
25
|
+
new(finding, source, terminal).render(message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(finding, source, terminal)
|
|
29
|
+
@finding = finding
|
|
30
|
+
@terminal = terminal
|
|
31
|
+
@source = source
|
|
32
|
+
@labels = [[finding.primary, PRIMARY, finding.severity], *finding.secondary.map { [it, SECONDARY, :secondary] }]
|
|
33
|
+
@numbers = @labels.map { it.first.span.start_line }.uniq.sort
|
|
34
|
+
@gutter = ' ' * @numbers.max.to_s.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render(message)
|
|
38
|
+
lines = [header(message), location, "#{@gutter} |", *body]
|
|
39
|
+
"#{lines.map(&:rstrip).join("\n")}\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def header(message) = "#{@terminal.paint(@finding.severity, @finding.severity)}[#{@finding.rule_id}]: #{message}"
|
|
45
|
+
|
|
46
|
+
def location
|
|
47
|
+
span = @finding.primary.span
|
|
48
|
+
suffix = INCONCLUSIVE if @finding.evidence == :inconclusive
|
|
49
|
+
"#{@gutter}--> #{span.path}:#{span.start_line}:#{span.start_column}#{suffix}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def body
|
|
53
|
+
@numbers.each_with_index.flat_map do |number, index|
|
|
54
|
+
[*(index.positive? ? gap(@numbers[index - 1], number) : []), numbered(number), *markers(number)]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# One skipped line is shown as source; more than one collapses to `...`.
|
|
59
|
+
def gap(previous, number)
|
|
60
|
+
case number - previous - 1
|
|
61
|
+
when 0 then []
|
|
62
|
+
when 1 then [numbered(previous + 1)]
|
|
63
|
+
else ['...']
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def numbered(number) = "#{number.to_s.rjust(@gutter.size)} | #{expand(@source.line(number))}"
|
|
68
|
+
|
|
69
|
+
def markers(number)
|
|
70
|
+
@labels.select { it.first.span.start_line == number }
|
|
71
|
+
.sort_by { |label, *| label.span.start_column }
|
|
72
|
+
.map { |label, marker, style| marker_line(label, marker, style) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def marker_line(label, marker, style)
|
|
76
|
+
start, width = columns(label.span)
|
|
77
|
+
"#{@gutter} | #{" " * start}#{@terminal.paint(marker * width, style)} #{label.text}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Display start and marker width. Multi-line spans run to the end of their first line;
|
|
81
|
+
# zero-length spans get one marker.
|
|
82
|
+
def columns(span)
|
|
83
|
+
text = @source.line(span.start_line)
|
|
84
|
+
start = expand(text[0, span.start_column - 1]).size
|
|
85
|
+
finish = expand(span.end_line > span.start_line ? text : text[0, span.end_column - 1]).size
|
|
86
|
+
[start, [finish - start, 1].max]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def expand(text) = text.to_s.gsub("\t", TAB)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|