moult 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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/LICENSE.txt +201 -0
  4. data/NOTICE +4 -0
  5. data/README.md +331 -0
  6. data/exe/moult +6 -0
  7. data/lib/moult/abc.rb +133 -0
  8. data/lib/moult/boundaries/packwerk.rb +114 -0
  9. data/lib/moult/boundaries/severity.rb +87 -0
  10. data/lib/moult/boundaries.rb +77 -0
  11. data/lib/moult/boundaries_report.rb +106 -0
  12. data/lib/moult/churn.rb +52 -0
  13. data/lib/moult/cli/boundaries_command.rb +83 -0
  14. data/lib/moult/cli/coverage_command.rb +101 -0
  15. data/lib/moult/cli/dead_code_command.rb +112 -0
  16. data/lib/moult/cli/duplication_command.rb +92 -0
  17. data/lib/moult/cli/flags_command.rb +95 -0
  18. data/lib/moult/cli/gate_command.rb +113 -0
  19. data/lib/moult/cli/health_command.rb +117 -0
  20. data/lib/moult/cli/hotspots_command.rb +104 -0
  21. data/lib/moult/cli.rb +102 -0
  22. data/lib/moult/clones.rb +91 -0
  23. data/lib/moult/cloud_upload.rb +29 -0
  24. data/lib/moult/confidence/rules.rb +128 -0
  25. data/lib/moult/confidence.rb +106 -0
  26. data/lib/moult/coverage/resolver.rb +56 -0
  27. data/lib/moult/coverage.rb +176 -0
  28. data/lib/moult/coverage_report.rb +98 -0
  29. data/lib/moult/dead_code.rb +119 -0
  30. data/lib/moult/dead_code_report.rb +65 -0
  31. data/lib/moult/diff.rb +177 -0
  32. data/lib/moult/discovery.rb +38 -0
  33. data/lib/moult/duplication/confidence.rb +92 -0
  34. data/lib/moult/duplication.rb +112 -0
  35. data/lib/moult/duplication_report.rb +89 -0
  36. data/lib/moult/flag_scanner.rb +150 -0
  37. data/lib/moult/flags/classification.rb +79 -0
  38. data/lib/moult/flags/snapshot.rb +162 -0
  39. data/lib/moult/flags/staleness.rb +145 -0
  40. data/lib/moult/flags.rb +131 -0
  41. data/lib/moult/flags_report.rb +136 -0
  42. data/lib/moult/formatters/boundaries_json.rb +20 -0
  43. data/lib/moult/formatters/boundaries_table.rb +53 -0
  44. data/lib/moult/formatters/coverage_json.rb +19 -0
  45. data/lib/moult/formatters/coverage_table.rb +60 -0
  46. data/lib/moult/formatters/dead_code_json.rb +20 -0
  47. data/lib/moult/formatters/dead_code_table.rb +66 -0
  48. data/lib/moult/formatters/duplication_json.rb +20 -0
  49. data/lib/moult/formatters/duplication_table.rb +55 -0
  50. data/lib/moult/formatters/flags_json.rb +20 -0
  51. data/lib/moult/formatters/flags_table.rb +76 -0
  52. data/lib/moult/formatters/gate_github.rb +52 -0
  53. data/lib/moult/formatters/gate_json.rb +20 -0
  54. data/lib/moult/formatters/gate_message.rb +19 -0
  55. data/lib/moult/formatters/gate_sarif.rb +78 -0
  56. data/lib/moult/formatters/gate_table.rb +71 -0
  57. data/lib/moult/formatters/health_json.rb +20 -0
  58. data/lib/moult/formatters/health_table.rb +80 -0
  59. data/lib/moult/formatters/json.rb +23 -0
  60. data/lib/moult/formatters/table.rb +70 -0
  61. data/lib/moult/formatters/text_table.rb +39 -0
  62. data/lib/moult/gate/config.rb +55 -0
  63. data/lib/moult/gate/evaluation.rb +172 -0
  64. data/lib/moult/gate/policy.rb +103 -0
  65. data/lib/moult/gate.rb +199 -0
  66. data/lib/moult/gate_report.rb +97 -0
  67. data/lib/moult/git.rb +83 -0
  68. data/lib/moult/health/score.rb +291 -0
  69. data/lib/moult/health.rb +320 -0
  70. data/lib/moult/health_report.rb +97 -0
  71. data/lib/moult/index.rb +228 -0
  72. data/lib/moult/parser.rb +101 -0
  73. data/lib/moult/rails_conventions.rb +124 -0
  74. data/lib/moult/report.rb +114 -0
  75. data/lib/moult/scoring.rb +82 -0
  76. data/lib/moult/span.rb +17 -0
  77. data/lib/moult/symbol_id.rb +30 -0
  78. data/lib/moult/symbol_scanner.rb +100 -0
  79. data/lib/moult/version.rb +5 -0
  80. data/lib/moult.rb +84 -0
  81. data/schema/boundaries.schema.json +125 -0
  82. data/schema/common.schema.json +76 -0
  83. data/schema/coverage.schema.json +83 -0
  84. data/schema/deadcode.schema.json +106 -0
  85. data/schema/duplication.schema.json +128 -0
  86. data/schema/flags.schema.json +157 -0
  87. data/schema/gate.schema.json +165 -0
  88. data/schema/health.schema.json +157 -0
  89. data/schema/hotspots.schema.json +106 -0
  90. metadata +185 -0
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # The serialized result model for `moult health` (schema/health.schema.json),
5
+ # sibling to {DuplicationReport}, {DeadCodeReport} and {CoverageReport}. It owns
6
+ # its own JSON envelope and leaves the other protected contracts untouched.
7
+ #
8
+ # The composite is a confidence-graded health SIGNAL, never a verdict: it records
9
+ # every contributing component (and every skipped/errored one) plus the reasons
10
+ # behind each sub-score, so the headline number is auditable. Nothing here asserts
11
+ # a pass/fail — that gate is Phase 4.
12
+ class HealthReport
13
+ # Bump only on a breaking change to the serialized shape.
14
+ SCHEMA_VERSION = 1
15
+
16
+ # One component of the composite, as serialized. +present+ false means the
17
+ # analysis was skipped (e.g. no --coverage) or errored; then +score+ and
18
+ # +normalized_weight+ are null and +diagnostic+ says why.
19
+ ComponentView = Struct.new(:name, :category, :present, :score, :weight,
20
+ :normalized_weight, :summary, :reasons, :diagnostic) do
21
+ def to_h
22
+ {
23
+ name: name,
24
+ category: category,
25
+ present: present,
26
+ score: score,
27
+ weight: weight,
28
+ normalized_weight: normalized_weight,
29
+ summary: summary,
30
+ reasons: reasons.map(&:to_h),
31
+ diagnostic: diagnostic
32
+ }
33
+ end
34
+ end
35
+
36
+ # One file's rolled-up health and the join keys that contributed to it.
37
+ # +components+ is a compact name => sub-score map (present components only).
38
+ FileView = Struct.new(:path, :score, :grade, :components, :symbol_ids, :symbol_count) do
39
+ def to_h
40
+ {
41
+ path: path,
42
+ score: score,
43
+ grade: grade,
44
+ components: components,
45
+ symbol_ids: symbol_ids,
46
+ symbol_count: symbol_count
47
+ }
48
+ end
49
+ end
50
+
51
+ attr_reader :root, :score, :grade, :components, :files, :git_ref, :generated_at,
52
+ :coverage_source, :churn_window, :churn_since
53
+
54
+ # @param root [String] absolute analysis root
55
+ # @param score [Float, nil] composite health in [0, 1]; nil when no component ran
56
+ # @param grade [String, nil] letter grade, or nil
57
+ # @param components [Array<ComponentView>] one per considered analysis, fixed order
58
+ # @param files [Array<FileView>] per-file roll-up, least-healthy first
59
+ # @param coverage_source [Coverage::Source, nil] provenance when coverage was merged
60
+ def initialize(root:, score:, grade:, components:, files:, git_ref: nil, generated_at: nil,
61
+ coverage_source: nil, churn_window: nil, churn_since: nil)
62
+ @root = root
63
+ @score = score
64
+ @grade = grade
65
+ @components = components
66
+ @files = files
67
+ @git_ref = git_ref
68
+ @generated_at = generated_at
69
+ @coverage_source = coverage_source
70
+ @churn_window = churn_window
71
+ @churn_since = churn_since
72
+ end
73
+
74
+ def to_h
75
+ {
76
+ schema_version: SCHEMA_VERSION,
77
+ tool: {name: "moult", version: Moult::VERSION},
78
+ analysis: {
79
+ root: root,
80
+ git_ref: git_ref,
81
+ generated_at: generated_at,
82
+ coverage: coverage_source&.to_h,
83
+ churn: {window: churn_window, since: churn_since}
84
+ },
85
+ overall: {
86
+ score: score,
87
+ grade: grade,
88
+ components_present: components.count(&:present),
89
+ components_total: components.size,
90
+ files_total: files.size
91
+ },
92
+ components: components.map(&:to_h),
93
+ files: files.map(&:to_h)
94
+ }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubydex"
4
+ require_relative "span"
5
+ require_relative "symbol_id"
6
+
7
+ module Moult
8
+ # The definition/reference index — Moult's adapter over the +rubydex+ gem and
9
+ # the *only* file that names +Rubydex+. Everything downstream consumes the
10
+ # Moult-owned {Index::Definition} value object, never a rubydex type, so the
11
+ # backend is swappable (the "swap, not rewrite" invariant).
12
+ #
13
+ # rubydex has two quirks this adapter normalises away (see test/test_index.rb):
14
+ #
15
+ # * Its locations are 0-based; Moult/Prism are 1-based. We add 1 to line
16
+ # numbers so dead-code symbol ids line up with {Scoring}'s hotspot ids.
17
+ # * Method references are not resolved to their target declaration (only
18
+ # constants are). So a method is considered "referenced" when its bare name
19
+ # appears anywhere in the call-site collection. This is deliberately
20
+ # conservative: a name collision can only *hide* a dead method, never invent
21
+ # a false positive — the safe direction for a confidence-graded tool.
22
+ class Index
23
+ # A definition site that is a candidate for the dead-code analysis. All
24
+ # fields are Moult-owned; no rubydex object leaks past this struct.
25
+ #
26
+ # @!attribute kind [Symbol] :method or :constant
27
+ # @!attribute visibility [Symbol] :public, :private, :protected
28
+ # @!attribute singleton [Boolean] true for Class.method / constants
29
+ # @!attribute reference_count [Integer] resolvable usages of this definition
30
+ # @!attribute reference_paths [Array<String>] root-relative paths that use it
31
+ # @!attribute override_of [String, nil] FQ name of the ancestor whose method
32
+ # this overrides/implements (reachable via that interface), else nil
33
+ Definition = Struct.new(
34
+ :symbol_id, :kind, :name, :unqualified_name, :owner,
35
+ :visibility, :singleton, :span, :path,
36
+ :reference_count, :reference_paths, :override_of
37
+ )
38
+
39
+ BUILTIN_SCHEME = "file:"
40
+
41
+ class << self
42
+ # @param root [String] absolute analysis root
43
+ # @param paths [Array<String>] absolute paths of Ruby files to index
44
+ # @return [Index]
45
+ def build(root:, paths:)
46
+ graph = Rubydex::Graph.new
47
+ graph.index_all(Array(paths))
48
+ graph.resolve
49
+ new(graph: graph, root: root)
50
+ rescue => e
51
+ raise Moult::Error, "rubydex indexing failed: #{e.class}: #{e.message}"
52
+ end
53
+
54
+ # Whether the rubydex backend is loadable. Always true once the gem is a
55
+ # hard dependency, but kept so live integration tests can skip cleanly.
56
+ def available?
57
+ require "rubydex"
58
+ true
59
+ rescue LoadError
60
+ false
61
+ end
62
+ end
63
+
64
+ def initialize(graph:, root:)
65
+ @graph = graph
66
+ # rubydex reports canonical (symlink-resolved) paths, so the root must be
67
+ # canonicalised too or workspace filtering misses everything on systems
68
+ # where e.g. /tmp -> /private/tmp.
69
+ @root = File.realpath(root.to_s)
70
+ rescue Errno::ENOENT
71
+ @root = root.to_s
72
+ end
73
+
74
+ # @return [Array<Index::Definition>] method + constant definition sites
75
+ # defined within the workspace, each with its resolved reference count.
76
+ def definitions
77
+ @definitions ||= method_definitions + constant_definitions
78
+ end
79
+
80
+ def resolved?
81
+ true
82
+ end
83
+
84
+ # @return [Array<String>] human-readable index diagnostics (non-fatal).
85
+ def diagnostics
86
+ @graph.diagnostics.map(&:to_s)
87
+ rescue
88
+ []
89
+ end
90
+
91
+ private
92
+
93
+ def method_definitions
94
+ @graph.declarations.select { |d| d.is_a?(Rubydex::Method) }.flat_map do |decl|
95
+ name = normalize_name(decl.name)
96
+ unqualified = strip_signature(decl.unqualified_name)
97
+ sites = method_call_sites[unqualified]
98
+ override = override_source(decl)
99
+ in_workspace_definitions(decl).map do |defn, span, rel|
100
+ Definition.new(
101
+ symbol_id: SymbolId.for(path: rel, start_line: span.start_line, fqname: name),
102
+ kind: :method,
103
+ name: name,
104
+ unqualified_name: unqualified,
105
+ owner: decl.owner&.name,
106
+ visibility: visibility_of(decl),
107
+ singleton: !name.include?("#"),
108
+ span: span,
109
+ path: rel,
110
+ reference_count: sites.size,
111
+ reference_paths: sites.compact.uniq,
112
+ override_of: override
113
+ )
114
+ end
115
+ end
116
+ end
117
+
118
+ def constant_definitions
119
+ @graph.declarations.select { |d| d.is_a?(Rubydex::Constant) }.flat_map do |decl|
120
+ refs = constant_reference_paths(decl)
121
+ in_workspace_definitions(decl).map do |defn, span, rel|
122
+ Definition.new(
123
+ symbol_id: SymbolId.for(path: rel, start_line: span.start_line, fqname: decl.name),
124
+ kind: :constant,
125
+ name: decl.name,
126
+ unqualified_name: strip_signature(decl.unqualified_name),
127
+ owner: decl.owner&.name,
128
+ visibility: visibility_of(decl),
129
+ singleton: true,
130
+ span: span,
131
+ path: rel,
132
+ reference_count: refs.size,
133
+ reference_paths: refs.compact.uniq,
134
+ override_of: nil
135
+ )
136
+ end
137
+ end
138
+ end
139
+
140
+ # rubydex does not resolve method references to a target, so we index every
141
+ # call site by its bare name: { "perform" => ["app/jobs/x.rb", ...] }.
142
+ def method_call_sites
143
+ @method_call_sites ||= @graph.method_references.each_with_object(Hash.new { |h, k| h[k] = [] }) do |ref, acc|
144
+ acc[ref.name.to_s] << workspace_relative(ref.location)
145
+ end
146
+ end
147
+
148
+ def constant_reference_paths(decl)
149
+ decl.references.map { |ref| workspace_relative(ref.location) }
150
+ rescue
151
+ []
152
+ end
153
+
154
+ # @return [Array<[definition, Span, rel_path]>] only sites inside the workspace
155
+ def in_workspace_definitions(decl)
156
+ decl.definitions.filter_map do |defn|
157
+ loc = defn.location
158
+ next unless in_workspace?(loc)
159
+ [defn, span_from(loc), workspace_relative(loc)]
160
+ end
161
+ end
162
+
163
+ def in_workspace?(location)
164
+ return false unless location
165
+ uri = location.uri
166
+ return false unless uri&.start_with?(BUILTIN_SCHEME)
167
+ file_path(location)&.start_with?(@root) || false
168
+ end
169
+
170
+ def workspace_relative(location)
171
+ path = file_path(location)
172
+ return nil unless path&.start_with?(@root)
173
+ SymbolId.relative_path(path, @root)
174
+ end
175
+
176
+ def file_path(location)
177
+ return nil unless location&.uri&.start_with?(BUILTIN_SCHEME)
178
+ location.to_file_path
179
+ rescue
180
+ nil
181
+ end
182
+
183
+ # rubydex lines are 0-based; Moult/Prism are 1-based. Columns already align.
184
+ def span_from(location)
185
+ Span.new(
186
+ start_line: location.start_line + 1,
187
+ start_column: location.start_column,
188
+ end_line: location.end_line + 1,
189
+ end_column: location.end_column
190
+ )
191
+ end
192
+
193
+ # The FQ name of the nearest ancestor (superclass or included module) that
194
+ # defines a method of the same name — meaning this definition overrides or
195
+ # implements it and is reachable through that ancestor's interface
196
+ # (polymorphic dispatch). nil when it overrides nothing in-workspace. Members
197
+ # are keyed by their signature form ("call()"), so the raw unqualified_name
198
+ # is the correct lookup key. External ancestors (gems) are only visible when
199
+ # their source has been indexed.
200
+ def override_source(decl)
201
+ owner = decl.owner
202
+ return nil unless owner
203
+ member_name = decl.unqualified_name
204
+ owner.ancestors.each do |ancestor|
205
+ next if ancestor.name == owner.name
206
+ return ancestor.name unless ancestor.member(member_name).nil?
207
+ end
208
+ nil
209
+ rescue
210
+ nil
211
+ end
212
+
213
+ def visibility_of(decl)
214
+ vis = decl.visibility if decl.respond_to?(:visibility)
215
+ %i[public private protected].include?(vis) ? vis : :public
216
+ end
217
+
218
+ # "Shop::Widget#helper()" => "Shop::Widget#helper";
219
+ # singleton "Acme::Service::<Service>#build()" => "Acme::Service.build"
220
+ def normalize_name(raw)
221
+ strip_signature(raw.to_s).sub(/::<[^>]+>#/, ".")
222
+ end
223
+
224
+ def strip_signature(raw)
225
+ raw.to_s.sub(/\(.*\z/m, "")
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "span"
5
+
6
+ module Moult
7
+ # A single method definition discovered by parsing. Internal to the analysis
8
+ # pipeline (not the serialized {Report::Method}); it retains the Prism node so
9
+ # the ABC metric can walk the method's subtree.
10
+ #
11
+ # +name+ is the *lexical* fully-qualified name (Class#method / Class.method),
12
+ # derived from the written module/class nesting only. Constant resolution
13
+ # (Zeitwerk, includes, reopened constants) is deliberately out of Phase 1.
14
+ MethodDef = Struct.new(:name, :span, :node)
15
+
16
+ # Pure parsing layer: source -> list of {MethodDef}. No IO beyond optionally
17
+ # reading a file; no git, no scoring. Trivially unit-testable on snippets.
18
+ module Parser
19
+ module_function
20
+
21
+ # @param path [String] file to read and parse
22
+ # @return [Array<MethodDef>]
23
+ def parse_file(path)
24
+ parse_source(File.read(path))
25
+ end
26
+
27
+ # @param source [String] Ruby source
28
+ # @return [Array<MethodDef>] in source order
29
+ def parse_source(source)
30
+ result = Prism.parse(source)
31
+ visitor = Visitor.new
32
+ result.value.accept(visitor)
33
+ visitor.methods
34
+ end
35
+
36
+ # Walks the AST tracking lexical class/module nesting and `class << self`
37
+ # context so each def gets a fully-qualified lexical name.
38
+ class Visitor < Prism::Visitor
39
+ attr_reader :methods
40
+
41
+ def initialize
42
+ @namespace = [] # stack of constant-path strings, e.g. ["A", "B::C"]
43
+ @singleton_context = [] # truthy frame == inside `class << self`
44
+ @methods = []
45
+ super
46
+ end
47
+
48
+ def visit_class_node(node)
49
+ @namespace.push(node.constant_path.slice)
50
+ super
51
+ @namespace.pop
52
+ end
53
+
54
+ def visit_module_node(node)
55
+ @namespace.push(node.constant_path.slice)
56
+ super
57
+ @namespace.pop
58
+ end
59
+
60
+ def visit_singleton_class_node(node)
61
+ # `class << self` makes nested defs singleton (class) methods of the
62
+ # enclosing namespace. Other `class << obj` forms are visited but not
63
+ # specially qualified.
64
+ @singleton_context.push(node.expression.is_a?(Prism::SelfNode))
65
+ super
66
+ @singleton_context.pop
67
+ end
68
+
69
+ def visit_def_node(node)
70
+ @methods << MethodDef.new(
71
+ name: qualified_name(node),
72
+ span: span_for(node),
73
+ node: node
74
+ )
75
+ super
76
+ end
77
+
78
+ private
79
+
80
+ def qualified_name(node)
81
+ singleton = !node.receiver.nil? || @singleton_context.last
82
+ separator = singleton ? "." : "#"
83
+ qualifier = @namespace.join("::")
84
+ return "#{separator}#{node.name}" if qualifier.empty? && singleton
85
+ return node.name.to_s if qualifier.empty?
86
+
87
+ "#{qualifier}#{separator}#{node.name}"
88
+ end
89
+
90
+ def span_for(node)
91
+ loc = node.location
92
+ Span.new(
93
+ start_line: loc.start_line,
94
+ start_column: loc.start_column,
95
+ end_line: loc.end_line,
96
+ end_column: loc.end_column
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "symbol_scanner"
4
+
5
+ module Moult
6
+ # Models the Rails entrypoint conventions that make framework-invoked code
7
+ # *look* unused. In Rails most "uncalled" methods are reached by convention
8
+ # (controller actions via routing, jobs via `#perform`) or by symbol-based
9
+ # DSLs (`before_action :authenticate`) that no static call site references.
10
+ #
11
+ # This layer never hides a finding (per Moult's core principle,
12
+ # metaprogramming/conventions must *lower* confidence, never silently hide). It only emits {Signal}s that
13
+ # the +:rails_entrypoint+ confidence rule turns into a strong, *explained*
14
+ # downward adjustment. A genuinely dead controller action therefore still
15
+ # appears — just sorted low — rather than being asserted alive.
16
+ #
17
+ # Scope (Tier A): controller/mailer actions, helpers, job `#perform`, symbol-
18
+ # DSL callbacks, serializers, initializers. Classes/modules are not flagged at
19
+ # all (only methods and non-class constants are candidates), which sidesteps
20
+ # STI/Zeitwerk false positives for this slice. Route-file and view-template
21
+ # resolution are deferred.
22
+ class RailsConventions
23
+ Signal = Struct.new(:rule, :detail)
24
+
25
+ CONTROLLER = %r{(\A|/)app/controllers/.+_controller\.rb\z}
26
+ MAILER = %r{(\A|/)app/mailers/.+\.rb\z}
27
+ HELPER = %r{(\A|/)app/helpers/.+\.rb\z}
28
+ JOB = %r{(\A|/)app/jobs/.+\.rb\z}
29
+ SERIALIZER = %r{(\A|/)app/serializers/.+\.rb\z}
30
+ INITIALIZER = %r{(\A|/)config/initializers/.+\.rb\z}
31
+
32
+ PERFORM_METHODS = %w[perform perform_async perform_later perform_now].freeze
33
+
34
+ class << self
35
+ # @param root [String] absolute analysis root
36
+ # @param files [Array<String>] absolute Ruby file paths (for DSL scan)
37
+ # @return [RailsConventions]
38
+ def build(root:, files:)
39
+ rails = rails_app?(root)
40
+ refs = rails ? collect_dsl_references(files) : Set.new
41
+ new(rails: rails, dsl_references: refs)
42
+ end
43
+
44
+ def rails_app?(root)
45
+ return true if File.file?(File.join(root, "config", "application.rb"))
46
+ File.directory?(File.join(root, "app")) && gemfile_mentions_rails?(root)
47
+ end
48
+
49
+ def gemfile_mentions_rails?(root)
50
+ gemfile = File.join(root, "Gemfile")
51
+ return false unless File.file?(gemfile)
52
+ File.foreach(gemfile).any? { |line| line =~ /^\s*gem\s+["'](rails|railties)["']/ }
53
+ rescue
54
+ false
55
+ end
56
+
57
+ def collect_dsl_references(files)
58
+ files.each_with_object(Set.new) do |path, set|
59
+ SymbolScanner.scan_file(path).each { |name| set << name }
60
+ rescue
61
+ next
62
+ end
63
+ end
64
+ end
65
+
66
+ # @param rails [Boolean] whether the project is a Rails app
67
+ # @param dsl_references [Set<String>] method names referenced via DSL symbols
68
+ def initialize(rails:, dsl_references: Set.new)
69
+ @rails = rails
70
+ @dsl_references = dsl_references
71
+ end
72
+
73
+ def rails?
74
+ @rails
75
+ end
76
+
77
+ # @param definition [Index::Definition]
78
+ # @return [Array<Signal>] matched entrypoint conventions (empty if none / not Rails)
79
+ def signals_for(definition)
80
+ return [] unless @rails
81
+
82
+ [path_signal(definition), symbol_signal(definition)].compact
83
+ end
84
+
85
+ private
86
+
87
+ def path_signal(definition)
88
+ return nil unless definition.kind == :method
89
+ path = definition.path.to_s
90
+
91
+ case path
92
+ when CONTROLLER
93
+ action_signal(definition, :rails_controller_action, "public action in #{path}")
94
+ when MAILER
95
+ action_signal(definition, :rails_mailer_action, "public mailer action in #{path}")
96
+ when HELPER
97
+ Signal.new(rule: :rails_helper, detail: "helper method in #{path}")
98
+ when JOB
99
+ if PERFORM_METHODS.include?(definition.unqualified_name)
100
+ Signal.new(rule: :rails_job_perform, detail: "job entrypoint #{definition.unqualified_name} in #{path}")
101
+ end
102
+ when SERIALIZER
103
+ Signal.new(rule: :rails_serializer, detail: "serializer method in #{path}")
104
+ when INITIALIZER
105
+ Signal.new(rule: :rails_initializer, detail: "runs at boot in #{path}")
106
+ end
107
+ end
108
+
109
+ # Public instance methods of controllers/mailers are framework-invoked
110
+ # actions; private/protected ones are helpers reached only via call or
111
+ # symbol-DSL, so they are left to the normal rules.
112
+ def action_signal(definition, rule, detail)
113
+ return nil unless definition.visibility == :public
114
+ Signal.new(rule: rule, detail: detail)
115
+ end
116
+
117
+ def symbol_signal(definition)
118
+ return nil unless definition.kind == :method
119
+ return nil unless @dsl_references.include?(definition.unqualified_name)
120
+
121
+ Signal.new(rule: :rails_callback, detail: "referenced as a DSL symbol (e.g. before_action :#{definition.unqualified_name})")
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # The in-memory result model and the source of the typed JSON output
5
+ # contract. This is one of Moult's two protected APIs (the other being the
6
+ # per-finding confidence model). Every analysis must be swappable behind this
7
+ # shape without changing it; both formatters render from this object so they
8
+ # cannot drift.
9
+ #
10
+ # The model reserves +confidence+ and +category+ on every finding even though
11
+ # Phase 1 never populates them: findings are confidence-graded, never asserted
12
+ # as certain death. Phases 2+ fill these in without a schema_version bump.
13
+ class Report
14
+ # Bump only on a breaking change to the serialized shape.
15
+ SCHEMA_VERSION = 1
16
+
17
+ attr_reader :root, :git_ref, :generated_at, :churn_window, :churn_since, :hotspots
18
+
19
+ # @param root [String] absolute path the analysis was rooted at
20
+ # @param hotspots [Array<Hotspot>] ranked, highest score first
21
+ # @param git_ref [String, nil] HEAD sha when run inside a repo
22
+ # @param generated_at [String, nil] ISO8601 timestamp
23
+ # @param churn_window [String, nil] human description of the churn window
24
+ # @param churn_since [String, nil] the resolved --since boundary (ISO8601 date)
25
+ def initialize(root:, hotspots:, git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil)
26
+ @root = root
27
+ @hotspots = hotspots
28
+ @git_ref = git_ref
29
+ @generated_at = generated_at
30
+ @churn_window = churn_window
31
+ @churn_since = churn_since
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ schema_version: SCHEMA_VERSION,
37
+ tool: {name: "moult", version: Moult::VERSION},
38
+ analysis: {
39
+ root: root,
40
+ git_ref: git_ref,
41
+ generated_at: generated_at,
42
+ churn: {window: churn_window, since: churn_since}
43
+ },
44
+ hotspots: hotspots.map(&:to_h)
45
+ }
46
+ end
47
+
48
+ # One ranked file.
49
+ class Hotspot
50
+ attr_reader :path, :score, :complexity, :churn, :methods, :confidence, :category
51
+
52
+ # @param path [String] path relative to the analysis root
53
+ # @param score [Float] complexity x churn
54
+ # @param complexity [Float] aggregate ABC for the file
55
+ # @param churn [Integer] commits touching the file in the window
56
+ # @param methods [Array<Method>] worst methods, highest ABC first
57
+ def initialize(path:, score:, complexity:, churn:, methods:, confidence: nil, category: nil)
58
+ @path = path
59
+ @score = score
60
+ @complexity = complexity
61
+ @churn = churn
62
+ @methods = methods
63
+ @confidence = confidence
64
+ @category = category
65
+ end
66
+
67
+ # The single worst method in the file, for table drill-down.
68
+ def worst_method
69
+ methods.first
70
+ end
71
+
72
+ def to_h
73
+ {
74
+ path: path,
75
+ score: score,
76
+ complexity: complexity,
77
+ churn: churn,
78
+ confidence: confidence,
79
+ category: category,
80
+ methods: methods.map(&:to_h)
81
+ }
82
+ end
83
+ end
84
+
85
+ # One method definition with its complexity.
86
+ class Method
87
+ attr_reader :symbol_id, :name, :span, :abc, :confidence, :category
88
+
89
+ # @param symbol_id [String] stable join key: "<path>:<start_line>:<fqname>"
90
+ # @param name [String] lexical fully-qualified name (Class#method / Class.method)
91
+ # @param span [Span] definition source range
92
+ # @param abc [Float] flog-style weighted ABC score
93
+ def initialize(symbol_id:, name:, span:, abc:, confidence: nil, category: nil)
94
+ @symbol_id = symbol_id
95
+ @name = name
96
+ @span = span
97
+ @abc = abc
98
+ @confidence = confidence
99
+ @category = category
100
+ end
101
+
102
+ def to_h
103
+ {
104
+ symbol_id: symbol_id,
105
+ name: name,
106
+ span: span.to_h,
107
+ abc: abc,
108
+ confidence: confidence,
109
+ category: category
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end