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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +4 -0
- data/README.md +331 -0
- data/exe/moult +6 -0
- data/lib/moult/abc.rb +133 -0
- data/lib/moult/boundaries/packwerk.rb +114 -0
- data/lib/moult/boundaries/severity.rb +87 -0
- data/lib/moult/boundaries.rb +77 -0
- data/lib/moult/boundaries_report.rb +106 -0
- data/lib/moult/churn.rb +52 -0
- data/lib/moult/cli/boundaries_command.rb +83 -0
- data/lib/moult/cli/coverage_command.rb +101 -0
- data/lib/moult/cli/dead_code_command.rb +112 -0
- data/lib/moult/cli/duplication_command.rb +92 -0
- data/lib/moult/cli/flags_command.rb +95 -0
- data/lib/moult/cli/gate_command.rb +113 -0
- data/lib/moult/cli/health_command.rb +117 -0
- data/lib/moult/cli/hotspots_command.rb +104 -0
- data/lib/moult/cli.rb +102 -0
- data/lib/moult/clones.rb +91 -0
- data/lib/moult/cloud_upload.rb +29 -0
- data/lib/moult/confidence/rules.rb +128 -0
- data/lib/moult/confidence.rb +106 -0
- data/lib/moult/coverage/resolver.rb +56 -0
- data/lib/moult/coverage.rb +176 -0
- data/lib/moult/coverage_report.rb +98 -0
- data/lib/moult/dead_code.rb +119 -0
- data/lib/moult/dead_code_report.rb +65 -0
- data/lib/moult/diff.rb +177 -0
- data/lib/moult/discovery.rb +38 -0
- data/lib/moult/duplication/confidence.rb +92 -0
- data/lib/moult/duplication.rb +112 -0
- data/lib/moult/duplication_report.rb +89 -0
- data/lib/moult/flag_scanner.rb +150 -0
- data/lib/moult/flags/classification.rb +79 -0
- data/lib/moult/flags/snapshot.rb +162 -0
- data/lib/moult/flags/staleness.rb +145 -0
- data/lib/moult/flags.rb +131 -0
- data/lib/moult/flags_report.rb +136 -0
- data/lib/moult/formatters/boundaries_json.rb +20 -0
- data/lib/moult/formatters/boundaries_table.rb +53 -0
- data/lib/moult/formatters/coverage_json.rb +19 -0
- data/lib/moult/formatters/coverage_table.rb +60 -0
- data/lib/moult/formatters/dead_code_json.rb +20 -0
- data/lib/moult/formatters/dead_code_table.rb +66 -0
- data/lib/moult/formatters/duplication_json.rb +20 -0
- data/lib/moult/formatters/duplication_table.rb +55 -0
- data/lib/moult/formatters/flags_json.rb +20 -0
- data/lib/moult/formatters/flags_table.rb +76 -0
- data/lib/moult/formatters/gate_github.rb +52 -0
- data/lib/moult/formatters/gate_json.rb +20 -0
- data/lib/moult/formatters/gate_message.rb +19 -0
- data/lib/moult/formatters/gate_sarif.rb +78 -0
- data/lib/moult/formatters/gate_table.rb +71 -0
- data/lib/moult/formatters/health_json.rb +20 -0
- data/lib/moult/formatters/health_table.rb +80 -0
- data/lib/moult/formatters/json.rb +23 -0
- data/lib/moult/formatters/table.rb +70 -0
- data/lib/moult/formatters/text_table.rb +39 -0
- data/lib/moult/gate/config.rb +55 -0
- data/lib/moult/gate/evaluation.rb +172 -0
- data/lib/moult/gate/policy.rb +103 -0
- data/lib/moult/gate.rb +199 -0
- data/lib/moult/gate_report.rb +97 -0
- data/lib/moult/git.rb +83 -0
- data/lib/moult/health/score.rb +291 -0
- data/lib/moult/health.rb +320 -0
- data/lib/moult/health_report.rb +97 -0
- data/lib/moult/index.rb +228 -0
- data/lib/moult/parser.rb +101 -0
- data/lib/moult/rails_conventions.rb +124 -0
- data/lib/moult/report.rb +114 -0
- data/lib/moult/scoring.rb +82 -0
- data/lib/moult/span.rb +17 -0
- data/lib/moult/symbol_id.rb +30 -0
- data/lib/moult/symbol_scanner.rb +100 -0
- data/lib/moult/version.rb +5 -0
- data/lib/moult.rb +84 -0
- data/schema/boundaries.schema.json +125 -0
- data/schema/common.schema.json +76 -0
- data/schema/coverage.schema.json +83 -0
- data/schema/deadcode.schema.json +106 -0
- data/schema/duplication.schema.json +128 -0
- data/schema/flags.schema.json +157 -0
- data/schema/gate.schema.json +165 -0
- data/schema/health.schema.json +157 -0
- data/schema/hotspots.schema.json +106 -0
- 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
|
data/lib/moult/index.rb
ADDED
|
@@ -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
|
data/lib/moult/parser.rb
ADDED
|
@@ -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
|
data/lib/moult/report.rb
ADDED
|
@@ -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
|