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
data/lib/snoot/cli.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "snoot/cli/event"
|
|
4
|
+
require "snoot/cli/pipeline"
|
|
5
|
+
|
|
6
|
+
module Snoot
|
|
7
|
+
# The CLI surface. cli.rb owns the surface entry points: argv-shape
|
|
8
|
+
# entry (.run -- UsageErrorExit), in-process entry (.run_invoked), the
|
|
9
|
+
# surface constants (banners, exit codes, the default path set), and
|
|
10
|
+
# the IO emitter helpers (emit_warnings, emit_failure,
|
|
11
|
+
# emit_nothing_to_report, format_report). The event values (RunInvoked,
|
|
12
|
+
# ReportEmitted) live in cli/event.rb; the Pipeline value lives in
|
|
13
|
+
# cli/pipeline.rb. .run consults BANNERS for --version, --help, and -h,
|
|
14
|
+
# rejects unknown flags with exit 64, and otherwise threads argv through
|
|
15
|
+
# .run_invoked, mapping the terminal outcome to an EXIT_CODES integer.
|
|
16
|
+
#
|
|
17
|
+
# The two entry points return deliberately different shapes. .run is
|
|
18
|
+
# the POSIX boundary: argv -> Integer, consumed by `exit
|
|
19
|
+
# Snoot::CLI.run(ARGV)` in exe/snoot. .run_invoked is the in-process
|
|
20
|
+
# boundary: Set<Path> -> [Run, Array of event values], used by tests
|
|
21
|
+
# and any library embedding that needs the full event list. Internally
|
|
22
|
+
# .run calls .run_invoked and projects Run#outcome through EXIT_CODES,
|
|
23
|
+
# so the integer is a lossy view of the Run; callers that need the Run,
|
|
24
|
+
# the events, or both must use .run_invoked.
|
|
25
|
+
module CLI
|
|
26
|
+
NOTHING_TO_REPORT = "nothing to report -- no findings above snoot's significance floor\n"
|
|
27
|
+
DEFAULT_PATHS = Set[Snoot::Path.new(raw: ".")].freeze
|
|
28
|
+
|
|
29
|
+
USAGE = <<~HELP
|
|
30
|
+
snoot - single-finding reek/flog/flay reporter
|
|
31
|
+
|
|
32
|
+
Usage: snoot [paths...]
|
|
33
|
+
snoot --version
|
|
34
|
+
snoot --help
|
|
35
|
+
|
|
36
|
+
With no path arguments, snoot scans the current directory.
|
|
37
|
+
|
|
38
|
+
Exit codes:
|
|
39
|
+
0 nothing to report
|
|
40
|
+
1 one finding rendered
|
|
41
|
+
2 analyser failure
|
|
42
|
+
64 usage error
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
snoot lib/
|
|
46
|
+
HELP
|
|
47
|
+
|
|
48
|
+
EXIT_CODES = {
|
|
49
|
+
finding_rendered: 1,
|
|
50
|
+
nothing_to_report: 0,
|
|
51
|
+
analysis_failed: 2,
|
|
52
|
+
usage_error: 64
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
BANNERS = {
|
|
56
|
+
%w[--version] => "snoot #{Snoot::VERSION}\n",
|
|
57
|
+
%w[-h --help] => USAGE
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
def emit_nothing_to_report(stdout)
|
|
63
|
+
stdout.write(NOTHING_TO_REPORT)
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def emit_failure(run, stderr)
|
|
68
|
+
failure = run.failure
|
|
69
|
+
stderr.write("analysis failed (#{failure.analyser}): #{failure.message}\n")
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def emit_warnings(analyse_events, stderr)
|
|
74
|
+
analyse_events.each do |event|
|
|
75
|
+
next unless event.is_a?(AnalyseRun::SkippedDocLessSmellWarned)
|
|
76
|
+
|
|
77
|
+
stderr.write("warning: skipping doc-less smell type '#{event.smell_type.name}'\n")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_report(sections)
|
|
82
|
+
"#{sections.values.join("\n\n")}\n"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run(argv, pipeline: Pipeline.default)
|
|
86
|
+
banner = lookup_banner(argv)
|
|
87
|
+
return write_and_return(pipeline.stdout, banner, 0) if banner
|
|
88
|
+
return write_and_return(pipeline.stderr, USAGE, EXIT_CODES.fetch(:usage_error)) if unknown_flag?(argv)
|
|
89
|
+
|
|
90
|
+
run_pipeline(argv, pipeline: pipeline)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def lookup_banner(argv)
|
|
94
|
+
return nil unless argv.length == 1
|
|
95
|
+
|
|
96
|
+
BANNERS.find { |flags, _| flags.include?(argv.first) }&.last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_and_return(io, message, code)
|
|
100
|
+
io.write(message)
|
|
101
|
+
code
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unknown_flag?(argv)
|
|
105
|
+
argv.any? { |arg| arg.start_with?("-") }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run_pipeline(argv, pipeline:)
|
|
109
|
+
run, _events = run_invoked(build_paths(argv), pipeline: pipeline)
|
|
110
|
+
EXIT_CODES.fetch(run.outcome)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_paths(argv)
|
|
114
|
+
argv.each_with_object(Set[]) { |raw, set| set << Path.new(raw: raw) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_invoked(paths, pipeline: Pipeline.default)
|
|
118
|
+
paths = DEFAULT_PATHS if paths.empty?
|
|
119
|
+
collect_events(paths, pipeline)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def collect_events(paths, pipeline)
|
|
123
|
+
AnalyseRun.invoke(paths, orchestration: pipeline.orchestration) =>
|
|
124
|
+
{ run:, events: analyse_events, smells: }
|
|
125
|
+
emit_warnings(analyse_events, pipeline.stderr)
|
|
126
|
+
events = [RunInvoked.new(paths: paths), *analyse_events,
|
|
127
|
+
*events_for_outcome(run, smells, pipeline: pipeline)]
|
|
128
|
+
[run, events]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def events_for_outcome(run, smells, pipeline:)
|
|
132
|
+
case run.outcome
|
|
133
|
+
when :finding_rendered then [emit_report(run, smells, pipeline: pipeline)]
|
|
134
|
+
when :nothing_to_report then emit_nothing_to_report(pipeline.stdout)
|
|
135
|
+
when :analysis_failed then emit_failure(run, pipeline.stderr)
|
|
136
|
+
else []
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def emit_report(run, smells, pipeline:)
|
|
141
|
+
RenderReport.invoke(run, smells: smells, orchestration: pipeline.orchestration) =>
|
|
142
|
+
{ sections:, finding: }
|
|
143
|
+
pipeline.stdout.write(format_report(sections))
|
|
144
|
+
ReportEmitted.new(run: run, finding: finding, sections: sections)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Snoot
|
|
6
|
+
# The three Finding variants -- the unit a Run can render. Distinct
|
|
7
|
+
# shapes; AnalyseRun's selection phase ranks within and across them.
|
|
8
|
+
|
|
9
|
+
# A single Reek smell instance. Ranked by how often its smell_type
|
|
10
|
+
# recurs within a Run.
|
|
11
|
+
Smell = Data.define(:smell_type, :location, :message)
|
|
12
|
+
|
|
13
|
+
# A single high-complexity method reported by Flog; score (BigDecimal)
|
|
14
|
+
# is the ranking key.
|
|
15
|
+
ComplexityHit = Data.define(:location, :method_name, :score)
|
|
16
|
+
|
|
17
|
+
# A structural-duplication cluster reported by Flay: a signature
|
|
18
|
+
# shared across two or more Locations. Cluster size (locations.size)
|
|
19
|
+
# is the ranking key.
|
|
20
|
+
DuplicationCluster = Data.define(:signature, :locations) do
|
|
21
|
+
def size = locations.size
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# Given a finding_rendered Run, produces a Report. Smell findings get
|
|
5
|
+
# two sections (doc, instances); ComplexityHit and DuplicationCluster
|
|
6
|
+
# get three (header, finding_context, doc). Per-section content is
|
|
7
|
+
# built by the helpers below.
|
|
8
|
+
module RenderReport
|
|
9
|
+
# Report is the value returned by RenderReport.invoke: the source
|
|
10
|
+
# Run, the selected Finding it was built from, and the ordered
|
|
11
|
+
# sections hash that CLI joins into stdout output.
|
|
12
|
+
Report = Data.define(:run, :finding, :sections)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def invoke(run, smells:, orchestration:)
|
|
17
|
+
finding = run.selected_finding
|
|
18
|
+
sections = if finding.is_a?(Smell)
|
|
19
|
+
smell_sections(smells, finding, orchestration)
|
|
20
|
+
else
|
|
21
|
+
non_smell_sections(finding)
|
|
22
|
+
end
|
|
23
|
+
Report.new(run: run, finding: finding, sections: sections)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def smell_sections(smells, smell, orchestration)
|
|
27
|
+
smell_type = smell.smell_type
|
|
28
|
+
matching = smells.select { |instance| instance.smell_type == smell_type }
|
|
29
|
+
{
|
|
30
|
+
doc: orchestration.vendored_doc(smell_type),
|
|
31
|
+
instances: render_instances(matching)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def non_smell_sections(finding)
|
|
36
|
+
case finding
|
|
37
|
+
when ComplexityHit then complexity_hit_sections(finding)
|
|
38
|
+
when DuplicationCluster then duplication_cluster_sections(finding)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def complexity_hit_sections(hit)
|
|
43
|
+
loc = hit.location.description
|
|
44
|
+
score = hit.score.to_s("F")
|
|
45
|
+
name = hit.method_name
|
|
46
|
+
{
|
|
47
|
+
header: "High complexity in #{name} at #{loc} (score: #{score})",
|
|
48
|
+
finding_context: "#{loc}\n\nMethod: #{name}\nScore: #{score}",
|
|
49
|
+
doc: "High complexity hits indicate a method or class doing too much. " \
|
|
50
|
+
"Consider extracting helpers, simplifying conditionals, or " \
|
|
51
|
+
"splitting the responsibility across smaller units."
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def duplication_cluster_sections(cluster)
|
|
56
|
+
locations = cluster.locations
|
|
57
|
+
rendered_locations = locations.map(&:description)
|
|
58
|
+
{
|
|
59
|
+
header: "Structural duplication: #{locations.size} locations (signature: #{cluster.signature})",
|
|
60
|
+
finding_context: "Locations:\n#{rendered_locations.join("\n")}",
|
|
61
|
+
doc: "Structural duplication suggests an extracted abstraction is missing. " \
|
|
62
|
+
"Consider whether the duplicated shape belongs to a single helper, " \
|
|
63
|
+
"module, or value type."
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_instances(smells)
|
|
68
|
+
groups = smell_groups_by_path(smells)
|
|
69
|
+
"## Instances\n\n#{groups.map { |path, group| render_instance_group(path, group) }.join("\n\n")}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def smell_groups_by_path(smells)
|
|
73
|
+
smells.group_by { |smell| smell.location.path.raw }
|
|
74
|
+
.sort_by { |path, group| [-group.size, path] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_instance_group(path, smells)
|
|
78
|
+
lines = smells.map { |smell| " Line #{smell.location.line_start}: #{smell.message}" }
|
|
79
|
+
"#{path}\n#{lines.join("\n")}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/snoot/run.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# One analysis pass: the input paths, the outcome (pending,
|
|
5
|
+
# finding_rendered, nothing_to_report, analysis_failed), and the
|
|
6
|
+
# selected_finding when one was chosen. Enforces the transition rules
|
|
7
|
+
# and the presence invariants -- selected_finding iff finding_rendered,
|
|
8
|
+
# failure iff analysis_failed.
|
|
9
|
+
Run = Data.define(:paths, :outcome, :selected_finding, :failure) do
|
|
10
|
+
def initialize(paths:, outcome:, selected_finding: nil, failure: nil)
|
|
11
|
+
super
|
|
12
|
+
enforce_field_when!(:selected_finding, outcome: :finding_rendered)
|
|
13
|
+
enforce_field_when!(:failure, outcome: :analysis_failed)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def transition_to(target, selected_finding: nil, failure: nil)
|
|
17
|
+
raise StateError, "transition #{outcome} -> #{target} is not declared" unless outcome == :pending
|
|
18
|
+
|
|
19
|
+
with(outcome: target, selected_finding: selected_finding, failure: failure)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def enforce_field_when!(field_name, outcome:)
|
|
25
|
+
actual = self.outcome
|
|
26
|
+
field_value = public_send(field_name)
|
|
27
|
+
if actual == outcome
|
|
28
|
+
raise StateError, "#{field_name} required for :#{outcome}" unless field_value
|
|
29
|
+
elsif field_value
|
|
30
|
+
raise StateError,
|
|
31
|
+
"#{field_name} only permitted when outcome = :#{outcome} (got #{actual.inspect})"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# StateError signals a violation of the Run state machine: an
|
|
5
|
+
# undeclared outcome transition, or a missing/extraneous
|
|
6
|
+
# selected_finding or failure relative to the run's outcome.
|
|
7
|
+
class StateError < StandardError
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snoot
|
|
4
|
+
# Value-type wrappers: no lifecycle, equal by value.
|
|
5
|
+
|
|
6
|
+
# A raw filesystem path string. Distinct from String so path-typed
|
|
7
|
+
# slots stay traceable.
|
|
8
|
+
Path = Data.define(:raw)
|
|
9
|
+
|
|
10
|
+
# A source range: inclusive line_start/line_end at a Path. Used by
|
|
11
|
+
# Smell, ComplexityHit, and each DuplicationCluster entry; carries its
|
|
12
|
+
# own #description so the AnalyserOrchestration contract need not.
|
|
13
|
+
Location = Data.define(:path, :line_start, :line_end) do
|
|
14
|
+
def description = "#{path.raw}:#{line_start}-#{line_end}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A Reek smell category, named (e.g. "IrresponsibleModule"). Groups
|
|
18
|
+
# Smells for ranking; keys vendored-doc lookup.
|
|
19
|
+
SmellType = Data.define(:name)
|
|
20
|
+
end
|
data/lib/snoot.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Top-level namespace for the snoot gem. See `snoot.allium` for the
|
|
4
|
+
# behavioural specification this implementation realises.
|
|
5
|
+
module Snoot
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require "snoot/version"
|
|
9
|
+
require "snoot/state_error"
|
|
10
|
+
require "snoot/value_types"
|
|
11
|
+
require "snoot/findings"
|
|
12
|
+
require "snoot/analyser_result"
|
|
13
|
+
require "snoot/run"
|
|
14
|
+
require "snoot/analyse_run"
|
|
15
|
+
require "snoot/analyse_run/result"
|
|
16
|
+
require "snoot/analyse_run/decision"
|
|
17
|
+
require "snoot/render_report"
|
|
18
|
+
require "snoot/analyser_orchestration"
|
|
19
|
+
require "snoot/analyser_orchestration/result_mapping"
|
|
20
|
+
require "snoot/analyser_orchestration/default"
|
|
21
|
+
require "snoot/cli"
|