snoot 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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +49 -0
- data/data/reek_docs/API.md +174 -0
- data/data/reek_docs/Attribute.md +39 -0
- data/data/reek_docs/Basic-Smell-Options.md +85 -0
- data/data/reek_docs/Boolean-Parameter.md +54 -0
- data/data/reek_docs/Class-Variable.md +40 -0
- data/data/reek_docs/Code-Smells.md +39 -0
- data/data/reek_docs/Command-Line-Options.md +119 -0
- data/data/reek_docs/Control-Couple.md +26 -0
- data/data/reek_docs/Control-Parameter.md +32 -0
- data/data/reek_docs/Data-Clump.md +46 -0
- data/data/reek_docs/Duplicate-Method-Call.md +264 -0
- data/data/reek_docs/Feature-Envy.md +93 -0
- data/data/reek_docs/How-To-Write-New-Detectors.md +144 -0
- data/data/reek_docs/How-reek-works-internally.md +114 -0
- data/data/reek_docs/Instance-Variable-Assumption.md +163 -0
- data/data/reek_docs/Irresponsible-Module.md +47 -0
- data/data/reek_docs/LICENSE +20 -0
- data/data/reek_docs/Large-Class.md +16 -0
- data/data/reek_docs/Long-Parameter-List.md +39 -0
- data/data/reek_docs/Long-Yield-List.md +37 -0
- data/data/reek_docs/Manual-Dispatch.md +30 -0
- data/data/reek_docs/Missing-Safe-Method.md +92 -0
- data/data/reek_docs/Module-Initialize.md +62 -0
- data/data/reek_docs/Nested-Iterators.md +59 -0
- data/data/reek_docs/Nil-Check.md +47 -0
- data/data/reek_docs/RSpec-matchers.md +129 -0
- data/data/reek_docs/Rake-Task.md +66 -0
- data/data/reek_docs/Reek-4-to-Reek-5-migration.md +188 -0
- data/data/reek_docs/Reek-Driven-Development.md +46 -0
- data/data/reek_docs/Repeated-Conditional.md +47 -0
- data/data/reek_docs/Simulated-Polymorphism.md +16 -0
- data/data/reek_docs/Smell-Suppression.md +96 -0
- data/data/reek_docs/Style-Guide.md +19 -0
- data/data/reek_docs/Subclassed-From-Core-Class.md +79 -0
- data/data/reek_docs/Too-Many-Constants.md +37 -0
- data/data/reek_docs/Too-Many-Instance-Variables.md +43 -0
- data/data/reek_docs/Too-Many-Methods.md +56 -0
- data/data/reek_docs/Too-Many-Statements.md +54 -0
- data/data/reek_docs/Uncommunicative-Method-Name.md +94 -0
- data/data/reek_docs/Uncommunicative-Module-Name.md +92 -0
- data/data/reek_docs/Uncommunicative-Name.md +18 -0
- data/data/reek_docs/Uncommunicative-Parameter-Name.md +90 -0
- data/data/reek_docs/Uncommunicative-Variable-Name.md +96 -0
- data/data/reek_docs/Unused-Parameters.md +28 -0
- data/data/reek_docs/Unused-Private-Method.md +101 -0
- data/data/reek_docs/Utility-Function.md +57 -0
- data/data/reek_docs/Versioning-Policy.md +7 -0
- data/data/reek_docs/YAML-Reports.md +93 -0
- data/exe/snoot +5 -0
- data/lib/snoot/analyse_run/decision.rb +62 -0
- data/lib/snoot/analyse_run/result.rb +12 -0
- data/lib/snoot/analyse_run.rb +70 -0
- data/lib/snoot/analyser_orchestration/default.rb +149 -0
- data/lib/snoot/analyser_orchestration/result_mapping.rb +52 -0
- data/lib/snoot/analyser_orchestration.rb +21 -0
- data/lib/snoot/analyser_result.rb +14 -0
- data/lib/snoot/cli/event.rb +13 -0
- data/lib/snoot/cli/pipeline.rb +14 -0
- data/lib/snoot/cli.rb +147 -0
- data/lib/snoot/findings.rb +23 -0
- data/lib/snoot/render_report.rb +82 -0
- data/lib/snoot/run.rb +35 -0
- data/lib/snoot/state_error.rb +9 -0
- data/lib/snoot/value_types.rb +20 -0
- data/lib/snoot/version.rb +5 -0
- data/lib/snoot.rb +21 -0
- data/snoot.allium +482 -0
- metadata +160 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
## Introduction
|
|
2
|
+
|
|
3
|
+
Classes should use their private methods. Otherwise this is dead
|
|
4
|
+
code which is confusing and bad for maintenance.
|
|
5
|
+
|
|
6
|
+
The _Unused Private Method_ detector reports unused private instance
|
|
7
|
+
methods and instance methods only - class methods are ignored.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
Given:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class Car
|
|
15
|
+
private
|
|
16
|
+
def drive; end
|
|
17
|
+
def start; end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Reek would emit the following warning:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
2 warnings:
|
|
25
|
+
[3]:Car has the unused private instance method `drive` (UnusedPrivateMethod)
|
|
26
|
+
[4]:Car has the unused private instance method `start` (UnusedPrivateMethod)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
_Unused Private Method_ offers the [Basic Smell Options](Basic-Smell-Options.md).
|
|
32
|
+
|
|
33
|
+
Private methods that are called via dynamic dispatch
|
|
34
|
+
will trigger a false alarm since detecting something like this is far out of
|
|
35
|
+
scope for Reek. In this case you can disable this detector via the `exclude`
|
|
36
|
+
configuration option (which is part of the [Basic Smell Options](Basic-Smell-Options.md))
|
|
37
|
+
for instance like this (an example from Reek's own codebase):
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# :reek:UnusedPrivateMethod { exclude: [ process_ ] }
|
|
41
|
+
class ContextBuilder
|
|
42
|
+
def process_begin
|
|
43
|
+
# ....
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Note that disabling this detector via comment works on a class scope, not
|
|
49
|
+
a method scope (like you can see above).
|
|
50
|
+
|
|
51
|
+
Another simple example would be:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
class Alfa
|
|
55
|
+
private
|
|
56
|
+
def bravo
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This would report:
|
|
62
|
+
|
|
63
|
+
>>
|
|
64
|
+
ruby.rb -- 1 warning:
|
|
65
|
+
[3]:UnusedPrivateMethod: Alfa has the unused private instance method 'bravo'
|
|
66
|
+
|
|
67
|
+
If you want to suppress this warning you can do this via source comment like this:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# :reek:UnusedPrivateMethod: { exclude: bravo }
|
|
71
|
+
class Alfa
|
|
72
|
+
private
|
|
73
|
+
def bravo
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Known limitations
|
|
79
|
+
|
|
80
|
+
* Method calls via dynamic dispatch (e.g. via `send`) is something Reek (or any other
|
|
81
|
+
static tool for that matter) cannot detect.
|
|
82
|
+
* Method calls via callback like [Rails filters](http://guides.rubyonrails.org/action_controller_overview.html#filters)
|
|
83
|
+
will trigger this as well, e.g.:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class BankController < ActionController::Base
|
|
87
|
+
before_action :audit
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
def audit
|
|
91
|
+
# ....
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
* Reek works on a per-file base. This means that using something like the [template pattern](https://en.wikipedia.org/wiki/Template_method_pattern)
|
|
96
|
+
with private methods will trigger this detector.
|
|
97
|
+
We do believe though that using private methods to fill out a template in a
|
|
98
|
+
superclass is not a good idea in general so this probably isn't really a problem
|
|
99
|
+
but still worth mentioning it.
|
|
100
|
+
|
|
101
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Utility Function
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
A _Utility Function_ is any instance method that has no dependency on the state of the instance.
|
|
6
|
+
|
|
7
|
+
_Utility Function_ is heavily related to _[Feature Envy](Feature-Envy.md)_, please check out the explanation there why _Utility Function_ is something you should care about.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
Given
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class UtilityFunction
|
|
15
|
+
def showcase(argument)
|
|
16
|
+
argument.to_s + argument.to_i
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Reek would report:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
test.rb -- 2 warnings:
|
|
25
|
+
[2]:UtilityFunction#showcase doesn't depend on instance state (UtilityFunction)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Current Support in Reek
|
|
29
|
+
|
|
30
|
+
_Utility Function_ will warn about any method that:
|
|
31
|
+
|
|
32
|
+
* is non-empty
|
|
33
|
+
* does not override an inherited method
|
|
34
|
+
* calls at least one method on another object
|
|
35
|
+
* doesn't use any of self's instance variables
|
|
36
|
+
* doesn't use any of self's methods
|
|
37
|
+
|
|
38
|
+
## Differences to _Feature Envy_
|
|
39
|
+
|
|
40
|
+
_[Feature Envy](Feature-Envy.md)_ is only triggered if there are some references to self and _Utility Function_ is triggered if there are no references to self.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Reek's _Utility Function_ detector supports the [Basic Smell Options](Basic-Smell-Options.md), plus:
|
|
45
|
+
|
|
46
|
+
| Option | Value | Effect |
|
|
47
|
+
| ----------------------|-------------|---------|
|
|
48
|
+
| `public_methods_only` | Boolean | Disable this smell detector for non-public methods (which means "private" and "protected") |
|
|
49
|
+
|
|
50
|
+
A sample configuration file would look like this:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
---
|
|
54
|
+
detectors:
|
|
55
|
+
UtilityFunction:
|
|
56
|
+
public_methods_only: true
|
|
57
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Versioning Policy
|
|
2
|
+
|
|
3
|
+
* CLI interface: Adding options is a non-breaking change, and would warrant an update of the minor version. Removing options is a breaking change and requires a major version update (we did this going to Reek 2). Adding a report format probably also warrants a minor version upgrade.
|
|
4
|
+
* API: We haven't really defined a 'public' API for using Reek programmatically, and we've only just started testing it. So, this is basically a blank slate at the moment. We will work on this as a part of the Reek 3 release.
|
|
5
|
+
* List of detected smells: Adding a smell warrants a minor release, removing a smell is a breaking change. This makes sense if you consider that the CLI allows running a single smell detector.
|
|
6
|
+
* Consistency of detected smells: This is very hard to guarantee. If we fix a bug in one of the detectors, some fragrant code may become smelly, or vice versa. Right now we don't bother with this.
|
|
7
|
+
* Smell configuration: The detectors are quite tolerant regarding configuration options that they don't recognize, so we regard any change here as only requiring a minor release.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# YAML Reports
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
Reek's `--yaml` option writes on $stdout a YAML dump of the smells found. Each reported smell has a number of standard fields and a number of fields that are specific to the smell's type. The common fields are as follows:
|
|
6
|
+
|
|
7
|
+
| Field | Type | Value |
|
|
8
|
+
| ---------------|-------------|---------|
|
|
9
|
+
| source | string | The name of the source file containing the smell, or `$stdin` |
|
|
10
|
+
| lines | array | The source file line number(s) that contribute to this smell |
|
|
11
|
+
| context | string | The name of the class, module or method containing the smell |
|
|
12
|
+
| class | string | The class to which this smell belongs |
|
|
13
|
+
| subclass | string | This smell's subclass within the above class |
|
|
14
|
+
| message | string | The message that would have been printed in a standard Reek report |
|
|
15
|
+
| is_active | boolean | `false` if the smell is masked by a config file; `true` otherwise |
|
|
16
|
+
|
|
17
|
+
All of these fields are grouped into hashes `location`, `smell` and `status` (see the examples below).
|
|
18
|
+
|
|
19
|
+
## Examples
|
|
20
|
+
|
|
21
|
+
Duplication:
|
|
22
|
+
|
|
23
|
+
<pre>
|
|
24
|
+
- !ruby/object:Reek::SmellWarning
|
|
25
|
+
location:
|
|
26
|
+
source: spec/samples/masked/dirty.rb
|
|
27
|
+
lines:
|
|
28
|
+
- 5
|
|
29
|
+
- 7
|
|
30
|
+
context: Dirty#a
|
|
31
|
+
smell:
|
|
32
|
+
class: Duplication
|
|
33
|
+
subclass: DuplicateMethodCall
|
|
34
|
+
occurrences: 2
|
|
35
|
+
call: puts(@s.title)
|
|
36
|
+
message: calls puts(@s.title) twice
|
|
37
|
+
status:
|
|
38
|
+
is_active: true
|
|
39
|
+
</pre>
|
|
40
|
+
|
|
41
|
+
[Nested Iterators](Nested-Iterators.md):
|
|
42
|
+
|
|
43
|
+
<pre>
|
|
44
|
+
- !ruby/object:Reek::SmellWarning
|
|
45
|
+
location:
|
|
46
|
+
source: spec/samples/masked/dirty.rb
|
|
47
|
+
lines:
|
|
48
|
+
- 5
|
|
49
|
+
context: Dirty#a
|
|
50
|
+
smell:
|
|
51
|
+
class: NestedIterators
|
|
52
|
+
subclass: ""
|
|
53
|
+
depth: 2
|
|
54
|
+
message: contains iterators nested 2 deep
|
|
55
|
+
status:
|
|
56
|
+
is_active: true
|
|
57
|
+
</pre>
|
|
58
|
+
|
|
59
|
+
[Uncommunicative Method Name](Uncommunicative-Method-Name.md):
|
|
60
|
+
|
|
61
|
+
<pre>
|
|
62
|
+
- !ruby/object:Reek::SmellWarning
|
|
63
|
+
location:
|
|
64
|
+
source: spec/samples/masked/dirty.rb
|
|
65
|
+
lines:
|
|
66
|
+
- 3
|
|
67
|
+
context: Dirty#a
|
|
68
|
+
smell:
|
|
69
|
+
class: UncommunicativeName
|
|
70
|
+
subclass: UncommunicativeMethodName
|
|
71
|
+
method_name: a
|
|
72
|
+
message: has the name 'a'
|
|
73
|
+
status:
|
|
74
|
+
is_active: false
|
|
75
|
+
</pre>
|
|
76
|
+
|
|
77
|
+
[Uncommunicative Variable Name](Uncommunicative-Variable-Name.md):
|
|
78
|
+
|
|
79
|
+
<pre>
|
|
80
|
+
- !ruby/object:Reek::SmellWarning
|
|
81
|
+
location:
|
|
82
|
+
source: spec/samples/masked/dirty.rb
|
|
83
|
+
lines:
|
|
84
|
+
- 5
|
|
85
|
+
context: Dirty#a
|
|
86
|
+
smell:
|
|
87
|
+
class: UncommunicativeName
|
|
88
|
+
subclass: UncommunicativeVariableName
|
|
89
|
+
variable_name: x
|
|
90
|
+
message: has the variable name 'x'
|
|
91
|
+
status:
|
|
92
|
+
is_active: true
|
|
93
|
+
</pre>
|
data/exe/snoot
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
module AnalyseRun
|
|
5
|
+
# Internal collaborator: bundles an AnalyserOrchestration with the
|
|
6
|
+
# Sources it produced so the chained selection helpers can share
|
|
7
|
+
# both as state instead of threading the pair through every call.
|
|
8
|
+
class Decision
|
|
9
|
+
def initialize(orchestration:, sources:)
|
|
10
|
+
@orchestration = orchestration
|
|
11
|
+
@sources = sources
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(run)
|
|
15
|
+
selected = selected_candidate
|
|
16
|
+
terminal = if selected
|
|
17
|
+
run.transition_to(:finding_rendered, selected_finding: selected)
|
|
18
|
+
else
|
|
19
|
+
run.transition_to(:nothing_to_report)
|
|
20
|
+
end
|
|
21
|
+
Result.new(run: terminal, events: doc_less_events(terminal), smells: @sources.smells)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def selected_candidate
|
|
27
|
+
AnalyseRun.select_top_finding(candidates)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def candidates
|
|
31
|
+
documented = @orchestration.significant_smells(@sources.smells)
|
|
32
|
+
.select { |smell| @orchestration.vendored_doc(smell.smell_type) }
|
|
33
|
+
.to_set
|
|
34
|
+
documented |
|
|
35
|
+
@orchestration.significant_complexities(@sources.complexities) |
|
|
36
|
+
@orchestration.significant_duplications(@sources.duplications)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def top_significant_smell
|
|
40
|
+
significant = @orchestration.significant_smells(@sources.smells)
|
|
41
|
+
AnalyseRun.top_smell(significant) if significant.any?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def doc_less_smell_type
|
|
45
|
+
smell = top_significant_smell
|
|
46
|
+
return nil unless smell
|
|
47
|
+
|
|
48
|
+
smell_type = smell.smell_type
|
|
49
|
+
return nil if @orchestration.vendored_doc(smell_type)
|
|
50
|
+
|
|
51
|
+
smell_type
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def doc_less_events(run)
|
|
55
|
+
smell_type = doc_less_smell_type
|
|
56
|
+
return [] unless smell_type
|
|
57
|
+
|
|
58
|
+
[SkippedDocLessSmellWarned.new(run: run, smell_type: smell_type)]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
module AnalyseRun
|
|
5
|
+
# Result is the value returned by AnalyseRun.invoke: the terminal Run,
|
|
6
|
+
# the audit events emitted along the way, and the smells the
|
|
7
|
+
# orchestration produced (forwarded to RenderReport, which filters
|
|
8
|
+
# by selected smell_type when building the per-file Instances list).
|
|
9
|
+
# smells is empty when analysis failed (no Sources were produced).
|
|
10
|
+
Result = Data.define(:run, :events, :smells)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# Turns a pending Run into a terminal outcome: orchestrates the three
|
|
5
|
+
# analysers, then selects one finding (or none, or signals failure).
|
|
6
|
+
# `invoke` returns an AnalyseRun::Result carrying the terminal Run, the
|
|
7
|
+
# events emitted along the way, and the raw smell set the orchestration
|
|
8
|
+
# produced.
|
|
9
|
+
module AnalyseRun
|
|
10
|
+
# SkippedDocLessSmellWarned carries the terminal Run and the
|
|
11
|
+
# offending smell_type that lacked a vendored doc.
|
|
12
|
+
SkippedDocLessSmellWarned = Data.define(:run, :smell_type)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def invoke(paths, orchestration:)
|
|
17
|
+
run = Run.new(paths: paths, outcome: :pending)
|
|
18
|
+
result = orchestration.analyse(paths)
|
|
19
|
+
return analysis_failure(run, result) if result.is_a?(AnalyserFailure)
|
|
20
|
+
|
|
21
|
+
Decision.new(orchestration: orchestration, sources: result).resolve(run)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def analysis_failure(run, failure)
|
|
25
|
+
failed = run.transition_to(:analysis_failed, failure: failure)
|
|
26
|
+
Result.new(run: failed, events: [], smells: Set[])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def select_top_finding(findings)
|
|
30
|
+
top_smell(findings.grep(Smell)) ||
|
|
31
|
+
top_duplication(findings.grep(DuplicationCluster)) ||
|
|
32
|
+
top_complexity(findings.grep(ComplexityHit))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def top_smell(smells)
|
|
36
|
+
counts = smells.group_by(&:smell_type).transform_values(&:size)
|
|
37
|
+
top_by(smells, metric: ->(smell) { counts[smell.smell_type] }, &method(:smell_sort_key))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def top_duplication(clusters)
|
|
41
|
+
top_by(clusters, metric: :size, &method(:duplication_sort_key))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def top_complexity(complexities)
|
|
45
|
+
top_by(complexities, metric: :score, &method(:complexity_sort_key))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def top_by(items, metric:, &sort_key)
|
|
49
|
+
pick = metric.to_proc
|
|
50
|
+
max = items.map(&pick).max
|
|
51
|
+
items.select { |item| pick.call(item) == max }.min_by(&sort_key)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def smell_sort_key(smell)
|
|
55
|
+
type = smell.smell_type
|
|
56
|
+
loc = smell.location
|
|
57
|
+
[type.name, loc.path.raw, loc.line_start]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def duplication_sort_key(cluster)
|
|
61
|
+
locs = cluster.locations
|
|
62
|
+
[cluster.signature, locs.map { |loc| loc.path.raw }.min, locs.map(&:line_start).min]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def complexity_sort_key(hit)
|
|
66
|
+
loc = hit.location
|
|
67
|
+
[loc.path.raw, loc.line_start]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "flay"
|
|
5
|
+
require "flog"
|
|
6
|
+
require "path_expander"
|
|
7
|
+
require "reek"
|
|
8
|
+
require "reek/cli/options"
|
|
9
|
+
require "reek/configuration/app_configuration"
|
|
10
|
+
require "reek/source/source_locator"
|
|
11
|
+
|
|
12
|
+
module Snoot
|
|
13
|
+
module AnalyserOrchestration
|
|
14
|
+
# Default is the production adapter for AnalyserOrchestration. It
|
|
15
|
+
# invokes the real Reek/Flog/Flay APIs in-process (no shellouts) and
|
|
16
|
+
# resolves vendored_doc against the reek docs vendored at
|
|
17
|
+
# data/reek_docs/<PascalCase-Hyphen>.md (synced via `rake docs:sync`,
|
|
18
|
+
# pinned to the bundled reek version). Reek invocation honours a
|
|
19
|
+
# project-local `.reek.yml` (or any ancestor's, falling back to
|
|
20
|
+
# `~/.reek.yml`) via `AppConfiguration.from_default_path`, matching
|
|
21
|
+
# reek's own CLI discovery. Flog scoring uses Flog's default options
|
|
22
|
+
# (every scored method emits a ComplexityHit; selection happens in
|
|
23
|
+
# AnalyseRun). Flay duplication uses Flay's default mass threshold
|
|
24
|
+
# (16). Stateless: implemented as a module of module functions, used
|
|
25
|
+
# as the orchestration value directly (no `.new`).
|
|
26
|
+
#
|
|
27
|
+
# Default's public surface is exactly the five contracted methods
|
|
28
|
+
# (vendored_doc, significant_smells, significant_complexities,
|
|
29
|
+
# significant_duplications, analyse). The per-analyser drivers
|
|
30
|
+
# (reek_analyse, flog_analyse, flay_analyse) and the per-pathname
|
|
31
|
+
# helper (reek_smells_for) are private; their behaviour is observed
|
|
32
|
+
# through analyse. Pure third-party-output translation is delegated
|
|
33
|
+
# to the sibling module ResultMapping.
|
|
34
|
+
#
|
|
35
|
+
# Per-analyser directory expansion mirrors each tool's own CLI
|
|
36
|
+
# default rather than imposing a snoot-wide glob, so a directory
|
|
37
|
+
# Path resolves exactly as that tool would resolve it on the command
|
|
38
|
+
# line. Reek defers to `Reek::Source::SourceLocator` (which also
|
|
39
|
+
# honours `.reek.yml exclude_paths`); Flog uses `**/*.{rb,rake}` to
|
|
40
|
+
# match `Flog::CLI`; Flay uses `**/*.rb` (Flay's CLI additionally
|
|
41
|
+
# appends extensions advertised by installed Flay plugins, which
|
|
42
|
+
# snoot does not load). The orchestration contract is path-abstract
|
|
43
|
+
# (snoot.allium:150), so this is implementation policy each adapter
|
|
44
|
+
# owns.
|
|
45
|
+
module Default
|
|
46
|
+
DOCS_ROOT = File.expand_path("../../../data/reek_docs", __dir__).freeze
|
|
47
|
+
DOC_FILENAME_PATTERN = /([a-z])([A-Z])/
|
|
48
|
+
|
|
49
|
+
SMELL_TYPE_INSTANCE_FLOOR = 2
|
|
50
|
+
COMPLEXITY_SCORE_FLOOR = BigDecimal("25")
|
|
51
|
+
|
|
52
|
+
ANALYSER_PROBES = [
|
|
53
|
+
%i[reek reek_analyse],
|
|
54
|
+
%i[flog flog_analyse],
|
|
55
|
+
%i[flay flay_analyse]
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Memoises vendored_doc results by smell_type.name. The corpus is
|
|
59
|
+
# fixed at gem build time (DOCS_ROOT, pinned to bundled reek) and
|
|
60
|
+
# the @invariant Determinism contract treats each call pure within
|
|
61
|
+
# a single CLI invocation, so caching across calls within a
|
|
62
|
+
# process is safe. nil (missing-doc) results are cached too.
|
|
63
|
+
@vendored_doc_cache = {}
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
def reek_analyse(paths)
|
|
68
|
+
config = Reek::Configuration::AppConfiguration.from_default_path
|
|
69
|
+
Reek::Source::SourceLocator.new(paths.map(&:raw), configuration: config).sources
|
|
70
|
+
.flat_map { |pathname| reek_smells_for(pathname, config) }
|
|
71
|
+
.to_set
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reek_smells_for(pathname, config)
|
|
75
|
+
examiner = Reek::Examiner.new(pathname, configuration: config)
|
|
76
|
+
examiner.smells.filter_map do |warning|
|
|
77
|
+
next unless warning.lines&.any?
|
|
78
|
+
|
|
79
|
+
ResultMapping.smell_from_reek_warning(warning)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def flog_analyse(paths)
|
|
84
|
+
files = PathExpander.new(paths.map(&:raw), "**/*.{rb,rake}").process
|
|
85
|
+
flog = Flog.new
|
|
86
|
+
flog.flog(*files)
|
|
87
|
+
flog.totals.filter_map do |class_method, score|
|
|
88
|
+
ResultMapping.complexity_hit_from_flog_entry(
|
|
89
|
+
class_method: class_method, score: score,
|
|
90
|
+
raw_location: flog.method_locations[class_method]
|
|
91
|
+
)
|
|
92
|
+
end.to_set
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def flay_analyse(paths)
|
|
96
|
+
files = PathExpander.new(paths.map(&:raw), "**/*.rb").process
|
|
97
|
+
flay = Flay.new
|
|
98
|
+
flay.process(*files)
|
|
99
|
+
flay.analyze.each_with_object(Set[]) do |item, clusters|
|
|
100
|
+
clusters << ResultMapping.duplication_cluster_from_flay_item(item)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def vendored_doc(smell_type)
|
|
105
|
+
name = smell_type.name
|
|
106
|
+
@vendored_doc_cache.fetch(name) do
|
|
107
|
+
path = File.join(DOCS_ROOT, "#{name.gsub(DOC_FILENAME_PATTERN, '\1-\2')}.md")
|
|
108
|
+
@vendored_doc_cache[name] = File.exist?(path) ? File.read(path) : nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def significant_smells(smells)
|
|
113
|
+
counts = smells.group_by(&:smell_type).transform_values(&:size)
|
|
114
|
+
smells.select { |smell| counts[smell.smell_type] >= SMELL_TYPE_INSTANCE_FLOOR }.to_set
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def significant_complexities(complexities)
|
|
118
|
+
complexities.select { |hit| hit.score >= COMPLEXITY_SCORE_FLOOR }.to_set
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def significant_duplications(duplications) = duplications
|
|
122
|
+
|
|
123
|
+
# analyse runs the three analysers in canonical order (Reek ->
|
|
124
|
+
# Flog -> Flay), capturing each result as it succeeds. On the
|
|
125
|
+
# first failure it returns an AnalyserFailure tagged with that
|
|
126
|
+
# analyser and does not invoke the remaining ones. On full
|
|
127
|
+
# success it returns a Sources bundling the three result sets.
|
|
128
|
+
def analyse(paths)
|
|
129
|
+
outputs = collect_outputs(paths)
|
|
130
|
+
return outputs if outputs.is_a?(AnalyserFailure)
|
|
131
|
+
|
|
132
|
+
Sources.new(
|
|
133
|
+
smells: outputs[:reek], complexities: outputs[:flog], duplications: outputs[:flay]
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def collect_outputs(paths)
|
|
138
|
+
ANALYSER_PROBES.each_with_object({}) do |(tag, method), outputs|
|
|
139
|
+
outputs[tag] = send(method, paths)
|
|
140
|
+
rescue StandardError => error
|
|
141
|
+
return AnalyserFailure.new(analyser: tag, message: error.message)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_class_method :reek_analyse, :reek_smells_for, :flog_analyse, :flay_analyse,
|
|
146
|
+
:collect_outputs
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Snoot
|
|
6
|
+
module AnalyserOrchestration
|
|
7
|
+
# ResultMapping converts third-party analyser outputs (Reek warnings,
|
|
8
|
+
# Flog totals entries, Flay items) into Snoot value objects. Pure: no
|
|
9
|
+
# IO, no third-party invocation. Default consumes these mappers
|
|
10
|
+
# internally as it drives reek/flog/flay; tests target this module's
|
|
11
|
+
# public surface directly with input doubles.
|
|
12
|
+
module ResultMapping
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def smell_from_reek_warning(warning)
|
|
16
|
+
lines = warning.lines
|
|
17
|
+
Smell.new(
|
|
18
|
+
smell_type: SmellType.new(name: warning.smell_type),
|
|
19
|
+
location: Location.new(
|
|
20
|
+
path: Path.new(raw: warning.source),
|
|
21
|
+
line_start: lines.first,
|
|
22
|
+
line_end: lines.last
|
|
23
|
+
),
|
|
24
|
+
message: "#{warning.context} #{warning.message}"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Flog stores method locations as "file:line" or "file:line-line_max".
|
|
29
|
+
# Returns nil when the entry is missing (e.g. main#none) so callers
|
|
30
|
+
# can skip top-level expressions that lack a method-level location.
|
|
31
|
+
def complexity_hit_from_flog_entry(class_method:, score:, raw_location:)
|
|
32
|
+
file, range = raw_location.to_s.split(":", 2)
|
|
33
|
+
return unless file && range
|
|
34
|
+
|
|
35
|
+
line_start, = range.split("-", 2).map(&:to_i)
|
|
36
|
+
ComplexityHit.new(
|
|
37
|
+
location: Location.new(path: Path.new(raw: file), line_start: line_start, line_end: line_start),
|
|
38
|
+
method_name: class_method,
|
|
39
|
+
score: BigDecimal(score.to_s)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def duplication_cluster_from_flay_item(item)
|
|
44
|
+
locations = item.locations.each_with_object(Set[]) do |loc, set|
|
|
45
|
+
line = loc.line
|
|
46
|
+
set << Location.new(path: Path.new(raw: loc.file), line_start: line, line_end: line)
|
|
47
|
+
end
|
|
48
|
+
DuplicationCluster.new(signature: item.structural_hash.to_s, locations: locations)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# The duck-typed orchestration contract the CLI demands -- any object
|
|
5
|
+
# responding to these five methods qualifies (no base class to inherit):
|
|
6
|
+
#
|
|
7
|
+
# vendored_doc(smell_type) -> String?
|
|
8
|
+
# significant_smells(smells) -> Set<Smell>
|
|
9
|
+
# significant_complexities(complexities) -> Set<ComplexityHit>
|
|
10
|
+
# significant_duplications(duplications) -> Set<DuplicationCluster>
|
|
11
|
+
# analyse(paths) -> Sources | AnalyserFailure
|
|
12
|
+
#
|
|
13
|
+
# Each call is pure within a single CLI invocation (see snoot.allium's
|
|
14
|
+
# Determinism invariant); outputs may differ across invocations as the
|
|
15
|
+
# source under analysis changes -- not a violation. The test double is
|
|
16
|
+
# Snoot::Spec::FakeOrchestration; the production adapter is
|
|
17
|
+
# Snoot::AnalyserOrchestration::Default. Report location rendering is
|
|
18
|
+
# Snoot::Location#description's job, not this contract's.
|
|
19
|
+
module AnalyserOrchestration
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# The two results of AnalyserOrchestration#analyse: Sources on full
|
|
5
|
+
# success, AnalyserFailure on the first analyser error.
|
|
6
|
+
|
|
7
|
+
# `analyser` is one of :reek, :flog, :flay (canonical order) -- which
|
|
8
|
+
# analyser failed; `message` is the error detail surfaced on stderr.
|
|
9
|
+
AnalyserFailure = Data.define(:analyser, :message)
|
|
10
|
+
|
|
11
|
+
# The three analyser outputs bundled for one Run; consumed by
|
|
12
|
+
# AnalyseRun's selection phase.
|
|
13
|
+
Sources = Data.define(:smells, :complexities, :duplications)
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
module CLI
|
|
5
|
+
# RunInvoked carries the (possibly defaulted) path set at the
|
|
6
|
+
# moment the surface is entered.
|
|
7
|
+
RunInvoked = Data.define(:paths)
|
|
8
|
+
|
|
9
|
+
# ReportEmitted carries the terminal Run, the selected Finding, and
|
|
10
|
+
# the rendered sections produced by RenderReport.
|
|
11
|
+
ReportEmitted = Data.define(:run, :finding, :sections)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
module CLI
|
|
5
|
+
# Pipeline bundles the analyser orchestration and the stdout/stderr
|
|
6
|
+
# pair that together define a CLI invocation's wiring -- the trio
|
|
7
|
+
# flows through run_invoked, the outcome dispatch, and emit_report.
|
|
8
|
+
Pipeline = Data.define(:orchestration, :stdout, :stderr) do
|
|
9
|
+
def self.default
|
|
10
|
+
new(orchestration: AnalyserOrchestration::Default, stdout: $stdout, stderr: $stderr)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|