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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE +21 -0
  4. data/README.md +49 -0
  5. data/data/reek_docs/API.md +174 -0
  6. data/data/reek_docs/Attribute.md +39 -0
  7. data/data/reek_docs/Basic-Smell-Options.md +85 -0
  8. data/data/reek_docs/Boolean-Parameter.md +54 -0
  9. data/data/reek_docs/Class-Variable.md +40 -0
  10. data/data/reek_docs/Code-Smells.md +39 -0
  11. data/data/reek_docs/Command-Line-Options.md +119 -0
  12. data/data/reek_docs/Control-Couple.md +26 -0
  13. data/data/reek_docs/Control-Parameter.md +32 -0
  14. data/data/reek_docs/Data-Clump.md +46 -0
  15. data/data/reek_docs/Duplicate-Method-Call.md +264 -0
  16. data/data/reek_docs/Feature-Envy.md +93 -0
  17. data/data/reek_docs/How-To-Write-New-Detectors.md +144 -0
  18. data/data/reek_docs/How-reek-works-internally.md +114 -0
  19. data/data/reek_docs/Instance-Variable-Assumption.md +163 -0
  20. data/data/reek_docs/Irresponsible-Module.md +47 -0
  21. data/data/reek_docs/LICENSE +20 -0
  22. data/data/reek_docs/Large-Class.md +16 -0
  23. data/data/reek_docs/Long-Parameter-List.md +39 -0
  24. data/data/reek_docs/Long-Yield-List.md +37 -0
  25. data/data/reek_docs/Manual-Dispatch.md +30 -0
  26. data/data/reek_docs/Missing-Safe-Method.md +92 -0
  27. data/data/reek_docs/Module-Initialize.md +62 -0
  28. data/data/reek_docs/Nested-Iterators.md +59 -0
  29. data/data/reek_docs/Nil-Check.md +47 -0
  30. data/data/reek_docs/RSpec-matchers.md +129 -0
  31. data/data/reek_docs/Rake-Task.md +66 -0
  32. data/data/reek_docs/Reek-4-to-Reek-5-migration.md +188 -0
  33. data/data/reek_docs/Reek-Driven-Development.md +46 -0
  34. data/data/reek_docs/Repeated-Conditional.md +47 -0
  35. data/data/reek_docs/Simulated-Polymorphism.md +16 -0
  36. data/data/reek_docs/Smell-Suppression.md +96 -0
  37. data/data/reek_docs/Style-Guide.md +19 -0
  38. data/data/reek_docs/Subclassed-From-Core-Class.md +79 -0
  39. data/data/reek_docs/Too-Many-Constants.md +37 -0
  40. data/data/reek_docs/Too-Many-Instance-Variables.md +43 -0
  41. data/data/reek_docs/Too-Many-Methods.md +56 -0
  42. data/data/reek_docs/Too-Many-Statements.md +54 -0
  43. data/data/reek_docs/Uncommunicative-Method-Name.md +94 -0
  44. data/data/reek_docs/Uncommunicative-Module-Name.md +92 -0
  45. data/data/reek_docs/Uncommunicative-Name.md +18 -0
  46. data/data/reek_docs/Uncommunicative-Parameter-Name.md +90 -0
  47. data/data/reek_docs/Uncommunicative-Variable-Name.md +96 -0
  48. data/data/reek_docs/Unused-Parameters.md +28 -0
  49. data/data/reek_docs/Unused-Private-Method.md +101 -0
  50. data/data/reek_docs/Utility-Function.md +57 -0
  51. data/data/reek_docs/Versioning-Policy.md +7 -0
  52. data/data/reek_docs/YAML-Reports.md +93 -0
  53. data/exe/snoot +5 -0
  54. data/lib/snoot/analyse_run/decision.rb +62 -0
  55. data/lib/snoot/analyse_run/result.rb +12 -0
  56. data/lib/snoot/analyse_run.rb +70 -0
  57. data/lib/snoot/analyser_orchestration/default.rb +149 -0
  58. data/lib/snoot/analyser_orchestration/result_mapping.rb +52 -0
  59. data/lib/snoot/analyser_orchestration.rb +21 -0
  60. data/lib/snoot/analyser_result.rb +14 -0
  61. data/lib/snoot/cli/event.rb +13 -0
  62. data/lib/snoot/cli/pipeline.rb +14 -0
  63. data/lib/snoot/cli.rb +147 -0
  64. data/lib/snoot/findings.rb +23 -0
  65. data/lib/snoot/render_report.rb +82 -0
  66. data/lib/snoot/run.rb +35 -0
  67. data/lib/snoot/state_error.rb +9 -0
  68. data/lib/snoot/value_types.rb +20 -0
  69. data/lib/snoot/version.rb +5 -0
  70. data/lib/snoot.rb +21 -0
  71. data/snoot.allium +482 -0
  72. 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,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "snoot"
5
+ exit Snoot::CLI.run(ARGV)
@@ -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