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
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snoot
4
+ VERSION = "0.1.0"
5
+ 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"