moult 0.4.1 → 0.4.2
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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +60 -7
- data/lib/moult/cli/cycles_command.rb +90 -0
- data/lib/moult/cli/hotspots_command.rb +14 -2
- data/lib/moult/cli.rb +2 -0
- data/lib/moult/confidence/rules.rb +25 -2
- data/lib/moult/confidence.rb +3 -1
- data/lib/moult/cycles.rb +131 -0
- data/lib/moult/cycles_report.rb +78 -0
- data/lib/moult/dead_code.rb +29 -3
- data/lib/moult/formatters/cycles_json.rb +20 -0
- data/lib/moult/formatters/cycles_table.rb +48 -0
- data/lib/moult/formatters/table.rb +5 -2
- data/lib/moult/index.rb +90 -2
- data/lib/moult/report.rb +15 -2
- data/lib/moult/scoring.rb +35 -5
- data/lib/moult/version.rb +1 -1
- data/lib/moult.rb +6 -0
- data/schema/cycles.schema.json +127 -0
- data/schema/hotspots.schema.json +17 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59531e8f248b8da451cabb11f828b81fb25e2c16d6226830eb90321a43ba26f1
|
|
4
|
+
data.tar.gz: c00350cef7dc2e9618b7fef715cc985e6fc708de655978bd76e0b15296f72c92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45b06f38d81c13873caaa309f993ffeea157e63b94b330d3f97cbb1f9a6d0e2b0240e1dce7096207d13bcd238185482011e02ab88cb6ed47558fc1955830b35c
|
|
7
|
+
data.tar.gz: 0ae644f96a6b055d95c6621e564427e8bb8b031f9a0f61b65c5a4a83dc4fe75b2302304454af6a4ad97b5e515b70b4db3866847e3bbc1c1877dc9dfdd0696845
|
data/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,20 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
14
|
### Added
|
|
15
|
+
- `moult cycles` — circular file dependencies over resolved constant
|
|
16
|
+
references (the dependency signal that survives Zeitwerk autoloading), with
|
|
17
|
+
a typed contract (`schema/cycles.schema.json`). Each finding carries its
|
|
18
|
+
member files, the in-cycle evidence edges, and a membership-stable
|
|
19
|
+
`cycle_group` fingerprint (`"scc:<hash>"`). Report-only; no gate
|
|
20
|
+
integration.
|
|
21
|
+
- `fan_in`/`fan_out`/`instability` on hotspot findings, from the same
|
|
22
|
+
constant-reference index (`--no-coupling` to skip). Coupling is context
|
|
23
|
+
only — the score stays `complexity × churn`. Additive/optional in the
|
|
24
|
+
schema, so `schema_version` stays 1.
|
|
25
|
+
- Hierarchy-aware dead-code confidence: a new `overrides_unreferenced_ancestor`
|
|
26
|
+
rule (mild −0.1 brake when the overridden ancestor type is itself
|
|
27
|
+
unreferenced outside tests) and a new `unreferenced_hierarchy` rule (+0.1
|
|
28
|
+
when neither the owner type nor any descendant is referenced outside tests).
|
|
15
29
|
- `clone_group` on duplication findings and gate contributions — a
|
|
16
30
|
`"<kind>:<structural-hash>"` join key (kind `identical`|`similar`) shared by
|
|
17
31
|
every occurrence of a clone group, so consumers can link exact twins. Null on
|
|
@@ -19,6 +33,9 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
19
33
|
`schema_version` stays 1.
|
|
20
34
|
|
|
21
35
|
### Changed
|
|
36
|
+
- `overrides_ancestor` (−0.4) now fires only when the overridden ancestor type
|
|
37
|
+
is live or of unknown liveness; a provably unreferenced ancestor downgrades
|
|
38
|
+
to the mild `overrides_unreferenced_ancestor` brake instead.
|
|
22
39
|
- The gate now emits one duplication contribution per in-diff occurrence
|
|
23
40
|
(previously one per clone group, attributed to its first in-diff
|
|
24
41
|
occurrence), so every site of a clone is visible downstream. Verdicts are
|
data/README.md
CHANGED
|
@@ -100,16 +100,23 @@ $ moult hotspots
|
|
|
100
100
|
|
|
101
101
|
Hotspots (complexity x churn): 3 files — churn over last 12 months
|
|
102
102
|
|
|
103
|
-
# SCORE COMPLEXITY CHURN FILE WORST METHOD
|
|
104
|
-
1 95.2 15.9 6 app/services/charge.rb Charge#call (15.9)
|
|
105
|
-
2 59.6 19.9 3 app/models/user.rb User#eligible? (15.9)
|
|
106
|
-
3 3.0 3.0 1 lib/util.rb Util.blank? (3.0)
|
|
103
|
+
# SCORE COMPLEXITY CHURN IN OUT INST FILE WORST METHOD
|
|
104
|
+
1 95.2 15.9 6 4 1 0.20 app/services/charge.rb Charge#call (15.9)
|
|
105
|
+
2 59.6 19.9 3 7 2 0.22 app/models/user.rb User#eligible? (15.9)
|
|
106
|
+
3 3.0 3.0 1 0 0 0.00 lib/util.rb Util.blank? (3.0)
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
`app/models/user.rb` is the most *complex* file, but `app/services/charge.rb`
|
|
110
110
|
tops the list because it changes twice as often — complexity alone would have
|
|
111
111
|
missed it.
|
|
112
112
|
|
|
113
|
+
`IN`/`OUT` are the file's fan-in and fan-out over **resolved constant
|
|
114
|
+
references** (the same index `deadcode` uses — the dependency signal that
|
|
115
|
+
survives Zeitwerk, where `require` lines are absent), and `INST` is the
|
|
116
|
+
instability ratio `fan_out / (fan_in + fan_out)`. Coupling is context, not a
|
|
117
|
+
factor in the score: weighting by instability would bury complex, churned,
|
|
118
|
+
high-fan-in files — exactly the hotspots that matter most.
|
|
119
|
+
|
|
113
120
|
### JSON
|
|
114
121
|
|
|
115
122
|
`--format json` emits the typed contract (see
|
|
@@ -136,6 +143,9 @@ moult hotspots --format json
|
|
|
136
143
|
"score": 95.22,
|
|
137
144
|
"complexity": 15.87,
|
|
138
145
|
"churn": 6,
|
|
146
|
+
"fan_in": 4,
|
|
147
|
+
"fan_out": 1,
|
|
148
|
+
"instability": 0.2,
|
|
139
149
|
"confidence": null,
|
|
140
150
|
"category": null,
|
|
141
151
|
"methods": [
|
|
@@ -163,6 +173,7 @@ today — Moult never asserts that code is dead.
|
|
|
163
173
|
| `--format table\|json` | `table` | Output format. |
|
|
164
174
|
| `--limit N` | `20` | Show the top N hotspots. `0` shows all. |
|
|
165
175
|
| `--since DATE` | `12 months ago` | Churn window start; any value `git log --since` accepts (e.g. `2025-01-01`). |
|
|
176
|
+
| `--[no-]coupling` | on | Compute fan-in/fan-out/instability from a constant-reference index. Off (or on index failure) the columns are `null`/`-`. |
|
|
166
177
|
| `--quiet` | off | Suppress informational notes on stderr. |
|
|
167
178
|
|
|
168
179
|
Moult is report-only: it exits `0` on success and non-zero only on error. There
|
|
@@ -200,12 +211,16 @@ metaprogramming, it never silently hides a candidate.
|
|
|
200
211
|
Each finding starts from a base score (by kind and visibility) and is adjusted
|
|
201
212
|
by a set of named rules, every one recorded as a `reason`:
|
|
202
213
|
|
|
203
|
-
- **Raises** confidence: a private method with no caller; a `@deprecated` mark
|
|
214
|
+
- **Raises** confidence: a private method with no caller; a `@deprecated` mark;
|
|
215
|
+
a method whose **owner type and all of its descendants are unreferenced**
|
|
216
|
+
outside tests (no constant path reaches the receiver at all).
|
|
204
217
|
- **Lowers** confidence: a public method (API surface); references only from
|
|
205
218
|
tests; a constructor (`initialize`, invoked implicitly by `.new`); a method
|
|
206
219
|
that **overrides or implements an ancestor's method** (reachable via that
|
|
207
|
-
interface — polymorphic dispatch)
|
|
208
|
-
|
|
220
|
+
interface — polymorphic dispatch) when the ancestor type is live or of
|
|
221
|
+
unknown liveness — an ancestor that is itself unreferenced outside tests
|
|
222
|
+
takes only a mild brake, since the whole hierarchy is likely dead together;
|
|
223
|
+
a file that uses `send`/`define_method`/`method_missing`/`const_get`/`eval`.
|
|
209
224
|
- **Lowers strongly**: a Rails entrypoint — controller/mailer actions, helpers,
|
|
210
225
|
job `#perform`, `before_action :symbol`-style callbacks, serializers,
|
|
211
226
|
initializers. Rails awareness is on automatically when a Rails app is detected
|
|
@@ -329,6 +344,44 @@ hot method Charge#call app/services/charge.rb:2
|
|
|
329
344
|
cold method Report#legacy_to_h lib/report.rb:42
|
|
330
345
|
```
|
|
331
346
|
|
|
347
|
+
## Cycles
|
|
348
|
+
|
|
349
|
+
```sh
|
|
350
|
+
moult cycles [PATH] [options]
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Lists **circular file dependencies**: strongly-connected groups in the file
|
|
354
|
+
graph whose edges are **resolved constant references** — the only dependency
|
|
355
|
+
signal that survives Zeitwerk autoloading, where `require`-based tools see
|
|
356
|
+
nothing. Superclass and mixin clauses flow through the same channel, so
|
|
357
|
+
inheritance edges are included.
|
|
358
|
+
|
|
359
|
+
```
|
|
360
|
+
$ moult cycles
|
|
361
|
+
|
|
362
|
+
Circular file dependencies (constant-resolved): 1 cycles
|
|
363
|
+
|
|
364
|
+
CONF SIZE CYCLE FILES
|
|
365
|
+
0.90 2 scc:64df19a0b7c2 app/models/account.rb -> app/models/user.rb -> app/models/account.rb
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`--format json` emits the typed contract (see
|
|
369
|
+
[`schema/cycles.schema.json`](schema/cycles.schema.json)). Each finding carries
|
|
370
|
+
its member `files`, the in-cycle `edges` (each with the referenced constant and
|
|
371
|
+
the reference site's span), and a `cycle_group` fingerprint —
|
|
372
|
+
`"scc:<hash of the sorted member files>"` — that is stable across runs and
|
|
373
|
+
machines, so cycles can be tracked over time. Confidence is `0.9`, not `1.0`:
|
|
374
|
+
a constant reopened in several files fans its edges out to every definition
|
|
375
|
+
file, which can widen a cycle beyond the code that actually participates.
|
|
376
|
+
|
|
377
|
+
| Flag | Default | Description |
|
|
378
|
+
|------|---------|-------------|
|
|
379
|
+
| `--format table\|json` | `table` | Output format. |
|
|
380
|
+
| `--quiet` | off | Suppress informational notes on stderr. |
|
|
381
|
+
|
|
382
|
+
Report-only: exit `0` on success, non-zero only on error. Cycles do not feed
|
|
383
|
+
the gate.
|
|
384
|
+
|
|
332
385
|
## How the score works
|
|
333
386
|
|
|
334
387
|
- **Complexity** — a flog-style weighted ABC score per method: assignments,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult cycles [PATH]` — list circular file dependencies over resolved
|
|
9
|
+
# constant references. Thin layer: parse options, build the index, drive
|
|
10
|
+
# the library, hand the {CyclesReport} to a formatter. Report-only: exit 0
|
|
11
|
+
# on success, non-zero only on error.
|
|
12
|
+
class CyclesCommand
|
|
13
|
+
# @return [Integer] process exit status
|
|
14
|
+
def run(argv)
|
|
15
|
+
options = parse(argv)
|
|
16
|
+
return puts_help(options) if options[:help]
|
|
17
|
+
|
|
18
|
+
root = File.expand_path(options[:path])
|
|
19
|
+
unless File.exist?(root)
|
|
20
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
21
|
+
return 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
report = analyze(root, options)
|
|
25
|
+
puts render(report, options)
|
|
26
|
+
0
|
|
27
|
+
rescue OptionParser::ParseError => e
|
|
28
|
+
warn "moult: #{e.message}"
|
|
29
|
+
1
|
|
30
|
+
rescue => e
|
|
31
|
+
warn "moult: #{e.message}"
|
|
32
|
+
1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse(argv)
|
|
38
|
+
options = {format: :table, quiet: false}
|
|
39
|
+
@parser = OptionParser.new do |o|
|
|
40
|
+
o.banner = "Usage: moult cycles [PATH] [options]"
|
|
41
|
+
o.separator ""
|
|
42
|
+
o.separator "Options:"
|
|
43
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
44
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
45
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
46
|
+
end
|
|
47
|
+
@parser.permute!(argv)
|
|
48
|
+
options[:path] = argv.shift || "."
|
|
49
|
+
options
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def puts_help(_options)
|
|
53
|
+
puts @parser
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def analyze(root, options)
|
|
58
|
+
root_dir, files = Support.discover(root)
|
|
59
|
+
index = Index.build(root: root_dir, paths: files)
|
|
60
|
+
edges = index.file_edges
|
|
61
|
+
note(options, "analysed #{files.size} files; #{edges.size} constant-resolved dependency edges.")
|
|
62
|
+
|
|
63
|
+
Cycles.build_report(
|
|
64
|
+
root: root_dir,
|
|
65
|
+
edges: edges,
|
|
66
|
+
git_ref: Git.head_ref(root_dir),
|
|
67
|
+
generated_at: Time.now.utc.iso8601,
|
|
68
|
+
backend_version: rubydex_version,
|
|
69
|
+
resolved: index.resolved?,
|
|
70
|
+
diagnostics: index.diagnostics
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render(report, options)
|
|
75
|
+
case options[:format]
|
|
76
|
+
when :json then Formatters::CyclesJson.render(report)
|
|
77
|
+
else Formatters::CyclesTable.render(report)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rubydex_version
|
|
82
|
+
defined?(Rubydex::VERSION) ? Rubydex::VERSION : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def note(options, message)
|
|
86
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -36,7 +36,7 @@ module Moult
|
|
|
36
36
|
private
|
|
37
37
|
|
|
38
38
|
def parse(argv)
|
|
39
|
-
options = {format: :table, limit: DEFAULT_LIMIT, since: Churn::DEFAULT_SINCE, quiet: false}
|
|
39
|
+
options = {format: :table, limit: DEFAULT_LIMIT, since: Churn::DEFAULT_SINCE, quiet: false, coupling: true}
|
|
40
40
|
@parser = OptionParser.new do |o|
|
|
41
41
|
o.banner = "Usage: moult hotspots [PATH] [options]"
|
|
42
42
|
o.separator ""
|
|
@@ -44,6 +44,7 @@ module Moult
|
|
|
44
44
|
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
45
45
|
o.on("--limit N", Integer, "Show top N hotspots (default #{DEFAULT_LIMIT}; 0 for all)") { |v| options[:limit] = v }
|
|
46
46
|
o.on("--since DATE", "Churn window start, any git --since value (default '#{Churn::DEFAULT_SINCE}')") { |v| options[:since] = v }
|
|
47
|
+
o.on("--[no-]coupling", "Add fan-in/fan-out/instability from a constant-reference index (default on)") { |v| options[:coupling] = v }
|
|
47
48
|
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
48
49
|
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
49
50
|
end
|
|
@@ -74,10 +75,21 @@ module Moult
|
|
|
74
75
|
git_ref: Git.head_ref(root_dir),
|
|
75
76
|
generated_at: Time.now.utc.iso8601,
|
|
76
77
|
churn_window: window_label(options[:since]),
|
|
77
|
-
churn_since: explicit_since(options[:since])
|
|
78
|
+
churn_since: explicit_since(options[:since]),
|
|
79
|
+
edges: coupling_edges(root_dir, files, options)
|
|
78
80
|
)
|
|
79
81
|
end
|
|
80
82
|
|
|
83
|
+
# nil (columns stay null) when coupling is off or the index fails —
|
|
84
|
+
# a broken index should not take the whole hotspots run down with it.
|
|
85
|
+
def coupling_edges(root_dir, files, options)
|
|
86
|
+
return nil unless options[:coupling]
|
|
87
|
+
Index.build(root: root_dir, paths: files).file_edges
|
|
88
|
+
rescue Moult::Error => e
|
|
89
|
+
note(options, "coupling unavailable (#{e.message}); columns left empty.")
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
81
93
|
def render(report, options)
|
|
82
94
|
limit = (options[:limit] && options[:limit] > 0) ? options[:limit] : nil
|
|
83
95
|
case options[:format]
|
data/lib/moult/cli.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Moult
|
|
|
14
14
|
"deadcode" => ["moult/cli/dead_code_command", :DeadCodeCommand],
|
|
15
15
|
"coverage" => ["moult/cli/coverage_command", :CoverageCommand],
|
|
16
16
|
"duplication" => ["moult/cli/duplication_command", :DuplicationCommand],
|
|
17
|
+
"cycles" => ["moult/cli/cycles_command", :CyclesCommand],
|
|
17
18
|
"health" => ["moult/cli/health_command", :HealthCommand],
|
|
18
19
|
"boundaries" => ["moult/cli/boundaries_command", :BoundariesCommand],
|
|
19
20
|
"flags" => ["moult/cli/flags_command", :FlagsCommand],
|
|
@@ -90,6 +91,7 @@ module Moult
|
|
|
90
91
|
moult deadcode [PATH] [options] List confidence-graded dead-code candidates
|
|
91
92
|
moult coverage [PATH] [options] Map symbols hot/cold/untracked from coverage
|
|
92
93
|
moult duplication [PATH] [options] List confidence-graded structural-clone groups
|
|
94
|
+
moult cycles [PATH] [options] List circular file dependencies (constant-resolved)
|
|
93
95
|
moult health [PATH] [options] Aggregate the analyses into a composite health score
|
|
94
96
|
moult boundaries [PATH] [options] List recorded architecture-boundary violations (packwerk)
|
|
95
97
|
moult flags [PATH] [options] Catalogue OpenFeature feature-flag references (usage)
|
|
@@ -65,13 +65,36 @@ module Moult
|
|
|
65
65
|
# through that ancestor's interface (polymorphic dispatch) even with no
|
|
66
66
|
# by-name call site — the same signal a typed tool gets free from its
|
|
67
67
|
# inheritance graph. Covers framework hooks (visitor #visit_*, job
|
|
68
|
-
# #perform) when the ancestor's source is indexed.
|
|
68
|
+
# #perform) when the ancestor's source is indexed. Fires when the
|
|
69
|
+
# ancestor type is live or of unknown liveness (Object, gems:
|
|
70
|
+
# conservative); a provably unreferenced ancestor downgrades to the
|
|
71
|
+
# weaker rule below instead.
|
|
69
72
|
Rule.new(
|
|
70
73
|
name: :overrides_ancestor,
|
|
71
|
-
applies: ->(c) { c.override_of },
|
|
74
|
+
applies: ->(c) { c.override_of && c.override_live != false },
|
|
72
75
|
delta: -0.4,
|
|
73
76
|
detail: ->(c) { "overrides #{c.override_of} (reachable via that interface)" }
|
|
74
77
|
),
|
|
78
|
+
# The overridden ancestor type itself has no production reference: the
|
|
79
|
+
# polymorphic path exists syntactically, but nothing names the type, so
|
|
80
|
+
# the hierarchy is likely dead together. A mild brake, not the full
|
|
81
|
+
# -0.4 — the reachability is mostly moot.
|
|
82
|
+
Rule.new(
|
|
83
|
+
name: :overrides_unreferenced_ancestor,
|
|
84
|
+
applies: ->(c) { c.override_of && c.override_live == false },
|
|
85
|
+
delta: -0.1,
|
|
86
|
+
detail: ->(c) { "overrides #{c.override_of}, but that ancestor type is itself unreferenced outside tests" }
|
|
87
|
+
),
|
|
88
|
+
# Neither the owner type nor any of its descendants is referenced in
|
|
89
|
+
# production: no constant path reaches the method's receiver at all.
|
|
90
|
+
# Modest raise — dynamic-dispatch and Rails rescue rules must still be
|
|
91
|
+
# able to counteract it.
|
|
92
|
+
Rule.new(
|
|
93
|
+
name: :unreferenced_hierarchy,
|
|
94
|
+
applies: ->(c) { c.hierarchy_referenced == false },
|
|
95
|
+
delta: 0.1,
|
|
96
|
+
detail: "no reference to the owner type or any of its descendants outside tests"
|
|
97
|
+
),
|
|
75
98
|
Rule.new(
|
|
76
99
|
name: :private_unused,
|
|
77
100
|
applies: ->(c) { c.kind == :method && c.visibility == :private && c.reference_count.to_i.zero? },
|
data/lib/moult/confidence.rb
CHANGED
|
@@ -39,7 +39,9 @@ module Moult
|
|
|
39
39
|
:override_of, # String, nil: ancestor whose method this overrides
|
|
40
40
|
:deprecated, # Boolean
|
|
41
41
|
:index_resolved,
|
|
42
|
-
:runtime
|
|
42
|
+
:runtime, # Symbol, nil: :hot/:cold/:untracked from coverage (Phase 3)
|
|
43
|
+
:override_live, # Boolean, nil: the overridden ancestor type has a production reference; nil = ancestor not indexed (unknown)
|
|
44
|
+
:hierarchy_referenced # Boolean, nil: the owner type or a descendant has a production reference; nil = owner not indexed (unknown)
|
|
43
45
|
)
|
|
44
46
|
|
|
45
47
|
# One auditable contribution to a finding's confidence.
|
data/lib/moult/cycles.rb
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# File-level circular dependencies over the {Index#file_edges} graph. Every
|
|
7
|
+
# edge is a *resolved* constant reference — the only dependency signal that
|
|
8
|
+
# survives Zeitwerk autoloading, where `require` lines are absent — so each
|
|
9
|
+
# cycle is backed by concrete reference sites, never name matching.
|
|
10
|
+
module Cycles
|
|
11
|
+
# High because every edge is a resolved constant reference, but not 1.0: a
|
|
12
|
+
# constant reopened in several files fans its edges out to every definition
|
|
13
|
+
# file, which can widen a cycle beyond the code that actually participates.
|
|
14
|
+
CONFIDENCE = 0.9
|
|
15
|
+
|
|
16
|
+
CATEGORY = "cycle"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param root [String] absolute analysis root
|
|
21
|
+
# @param edges [Array<Index::Edge>] file dependency edges
|
|
22
|
+
# @return [CyclesReport] findings ranked largest cycle first
|
|
23
|
+
def build_report(root:, edges:, git_ref: nil, generated_at: nil,
|
|
24
|
+
backend_version: nil, resolved: true, diagnostics: [])
|
|
25
|
+
components = strongly_connected(adjacency(edges))
|
|
26
|
+
findings = components.select { |c| c.size >= 2 }.map { |c| finding_for(c, edges) }
|
|
27
|
+
findings.sort_by! { |f| [-f.size, f.files.first] }
|
|
28
|
+
CyclesReport.new(
|
|
29
|
+
root: root, findings: findings, git_ref: git_ref, generated_at: generated_at,
|
|
30
|
+
backend: "rubydex", backend_version: backend_version,
|
|
31
|
+
resolved: resolved, diagnostics: diagnostics
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sorted node => sorted children over every file named by an edge, so the
|
|
36
|
+
# SCC walk (and therefore the report) is deterministic regardless of the
|
|
37
|
+
# order edges arrive in.
|
|
38
|
+
def adjacency(edges)
|
|
39
|
+
adj = {}
|
|
40
|
+
edges.each do |edge|
|
|
41
|
+
(adj[edge.src] ||= []) << edge.dst
|
|
42
|
+
adj[edge.dst] ||= []
|
|
43
|
+
end
|
|
44
|
+
adj.keys.sort.to_h { |node| [node, adj[node].uniq.sort] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Tarjan's strongly-connected components with an explicit work stack.
|
|
48
|
+
# ponytail: hand-rolled because stdlib TSort's SCC walk is recursive and
|
|
49
|
+
# SystemStackErrors on cycles a few thousand files deep (measured at ~5k);
|
|
50
|
+
# swap back to TSort if it ever gains an iterative walker.
|
|
51
|
+
def strongly_connected(adj)
|
|
52
|
+
index = {}
|
|
53
|
+
lowlink = {}
|
|
54
|
+
on_stack = {}
|
|
55
|
+
stack = []
|
|
56
|
+
components = []
|
|
57
|
+
counter = 0
|
|
58
|
+
|
|
59
|
+
adj.each_key do |start|
|
|
60
|
+
next if index.key?(start)
|
|
61
|
+
work = [[start, 0]]
|
|
62
|
+
until work.empty?
|
|
63
|
+
frame = work.last
|
|
64
|
+
node = frame[0]
|
|
65
|
+
if frame[1].zero? # first visit
|
|
66
|
+
index[node] = lowlink[node] = counter
|
|
67
|
+
counter += 1
|
|
68
|
+
stack << node
|
|
69
|
+
on_stack[node] = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
children = adj[node]
|
|
73
|
+
pushed = false
|
|
74
|
+
while frame[1] < children.size
|
|
75
|
+
child = children[frame[1]]
|
|
76
|
+
frame[1] += 1
|
|
77
|
+
if !index.key?(child)
|
|
78
|
+
work << [child, 0]
|
|
79
|
+
pushed = true
|
|
80
|
+
break
|
|
81
|
+
elsif on_stack[child]
|
|
82
|
+
lowlink[node] = [lowlink[node], index[child]].min
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
next if pushed
|
|
86
|
+
|
|
87
|
+
work.pop
|
|
88
|
+
if lowlink[node] == index[node]
|
|
89
|
+
component = []
|
|
90
|
+
loop do
|
|
91
|
+
member = stack.pop
|
|
92
|
+
on_stack.delete(member)
|
|
93
|
+
component << member
|
|
94
|
+
break if member == node
|
|
95
|
+
end
|
|
96
|
+
components << component
|
|
97
|
+
end
|
|
98
|
+
parent = work.last&.first
|
|
99
|
+
lowlink[parent] = [lowlink[parent], lowlink[node]].min if parent
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
components
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def finding_for(component, edges)
|
|
106
|
+
files = component.sort
|
|
107
|
+
member = component.to_h { |f| [f, true] }
|
|
108
|
+
evidence = edges.select { |e| member[e.src] && member[e.dst] && e.src != e.dst }
|
|
109
|
+
.sort_by { |e| [e.src, e.dst] }
|
|
110
|
+
CyclesReport::Finding.new(
|
|
111
|
+
cycle_group: fingerprint(files),
|
|
112
|
+
confidence: CONFIDENCE,
|
|
113
|
+
category: CATEGORY,
|
|
114
|
+
size: files.size,
|
|
115
|
+
files: files,
|
|
116
|
+
reasons: [Confidence::Reason.new(
|
|
117
|
+
rule: :resolved_constant_edges,
|
|
118
|
+
delta: CONFIDENCE,
|
|
119
|
+
detail: "every edge is a resolved constant reference; reopened constants can widen a cycle"
|
|
120
|
+
)],
|
|
121
|
+
edges: evidence
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Membership-stable across runs and machines (unlike a detector-backend
|
|
126
|
+
# hash): the same set of files is the same cycle, however its edges shift.
|
|
127
|
+
def fingerprint(files)
|
|
128
|
+
"scc:#{Digest::SHA256.hexdigest(files.join("\n"))[0, 12]}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
# The serialized result model for `moult cycles` (schema/cycles.schema.json),
|
|
5
|
+
# sibling to {DuplicationReport} and {DeadCodeReport}. Each {Finding} is one
|
|
6
|
+
# strongly-connected component of the file dependency graph, carrying the
|
|
7
|
+
# resolved constant-reference edges as its evidence.
|
|
8
|
+
class CyclesReport
|
|
9
|
+
# Bump only on a breaking change to the serialized shape.
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
# One circular file-dependency group. +cycle_group+ ("scc:<hash of the
|
|
13
|
+
# sorted member files>") is the group's join key — membership-stable
|
|
14
|
+
# across runs and machines, unlike a detector-backend hash. +edges+ are
|
|
15
|
+
# the in-cycle dependencies, each with a representative reference site.
|
|
16
|
+
Finding = Struct.new(:cycle_group, :confidence, :category, :size, :files, :reasons, :edges) do
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
cycle_group: cycle_group,
|
|
20
|
+
category: category,
|
|
21
|
+
confidence: confidence,
|
|
22
|
+
size: size,
|
|
23
|
+
files: files,
|
|
24
|
+
reasons: reasons.map(&:to_h),
|
|
25
|
+
edges: edges.map { |e| {src: e.src, dst: e.dst, constant: e.constant, span: e.span.to_h} }
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :root, :findings, :git_ref, :generated_at,
|
|
31
|
+
:backend, :backend_version, :resolved, :diagnostics
|
|
32
|
+
|
|
33
|
+
# @param root [String] absolute analysis root
|
|
34
|
+
# @param findings [Array<Finding>] ranked, largest cycle first
|
|
35
|
+
# @param backend [String] index backend name (e.g. "rubydex")
|
|
36
|
+
# @param backend_version [String, nil] backend gem version
|
|
37
|
+
def initialize(root:, findings:, git_ref: nil, generated_at: nil,
|
|
38
|
+
backend: "rubydex", backend_version: nil, resolved: true, diagnostics: [])
|
|
39
|
+
@root = root
|
|
40
|
+
@findings = findings
|
|
41
|
+
@git_ref = git_ref
|
|
42
|
+
@generated_at = generated_at
|
|
43
|
+
@backend = backend
|
|
44
|
+
@backend_version = backend_version
|
|
45
|
+
@resolved = resolved
|
|
46
|
+
@diagnostics = diagnostics
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Hash] aggregate counts across all cycles
|
|
50
|
+
def summary
|
|
51
|
+
{
|
|
52
|
+
cycles: findings.size,
|
|
53
|
+
files: findings.sum { |f| f.files.size },
|
|
54
|
+
largest: findings.map(&:size).max || 0
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
schema_version: SCHEMA_VERSION,
|
|
61
|
+
tool: {name: "moult", version: Moult::VERSION},
|
|
62
|
+
analysis: {
|
|
63
|
+
root: root,
|
|
64
|
+
git_ref: git_ref,
|
|
65
|
+
generated_at: generated_at,
|
|
66
|
+
index: {
|
|
67
|
+
backend: backend,
|
|
68
|
+
backend_version: backend_version,
|
|
69
|
+
resolved: resolved,
|
|
70
|
+
diagnostics: diagnostics
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
summary: summary,
|
|
74
|
+
findings: findings.map(&:to_h)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/moult/dead_code.rb
CHANGED
|
@@ -40,10 +40,12 @@ module Moult
|
|
|
40
40
|
def build_report(root:, files:, index:, rails:, min_confidence: 0.0,
|
|
41
41
|
git_ref: nil, generated_at: nil, backend_version: nil, coverage: nil)
|
|
42
42
|
dynamic_files = dynamic_dispatch_files(files, root)
|
|
43
|
+
constant_live = constant_liveness(index)
|
|
43
44
|
|
|
44
45
|
findings = index.definitions.filter_map do |definition|
|
|
45
46
|
next unless candidate?(definition)
|
|
46
|
-
Confidence.score(context_for(definition, index: index, rails: rails, dynamic_files: dynamic_files,
|
|
47
|
+
Confidence.score(context_for(definition, index: index, rails: rails, dynamic_files: dynamic_files,
|
|
48
|
+
coverage: coverage, constant_live: constant_live))
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
findings.select! { |f| f.confidence >= min_confidence }
|
|
@@ -68,7 +70,7 @@ module Moult
|
|
|
68
70
|
non_test_reference_paths(definition).empty?
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
def context_for(definition, index:, rails:, dynamic_files:, coverage: nil)
|
|
73
|
+
def context_for(definition, index:, rails:, dynamic_files:, coverage: nil, constant_live: {})
|
|
72
74
|
Confidence::Context.new(
|
|
73
75
|
symbol_id: definition.symbol_id,
|
|
74
76
|
kind: definition.kind,
|
|
@@ -83,10 +85,34 @@ module Moult
|
|
|
83
85
|
override_of: definition.override_of,
|
|
84
86
|
deprecated: false,
|
|
85
87
|
index_resolved: index.resolved?,
|
|
86
|
-
runtime: runtime_for(definition, coverage)
|
|
88
|
+
runtime: runtime_for(definition, coverage),
|
|
89
|
+
override_live: definition.override_of && constant_live[definition.override_of],
|
|
90
|
+
hierarchy_referenced: hierarchy_referenced?(definition)
|
|
87
91
|
)
|
|
88
92
|
end
|
|
89
93
|
|
|
94
|
+
# Whether the owner type or any descendant is referenced outside tests.
|
|
95
|
+
# nil (unknown) propagates from the index; test filtering stays here,
|
|
96
|
+
# matching the reference_paths convention — the Index never learns what a
|
|
97
|
+
# test path is.
|
|
98
|
+
def hierarchy_referenced?(definition)
|
|
99
|
+
paths = definition.owner_hierarchy_reference_paths
|
|
100
|
+
return nil if paths.nil?
|
|
101
|
+
Array(paths).reject { |path| path.to_s.match?(TEST_PATH) }.any?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# FQ constant name => whether it has a production (non-test) reference.
|
|
105
|
+
# The liveness join for override_live: an override's ancestor *method*
|
|
106
|
+
# shares the override's name-keyed references, so the honest signal is the
|
|
107
|
+
# ancestor *type*'s constant references. Reopened constants OR together;
|
|
108
|
+
# a name absent from this map (Object, gems) stays nil — unknown.
|
|
109
|
+
def constant_liveness(index)
|
|
110
|
+
index.definitions.each_with_object({}) do |d, acc|
|
|
111
|
+
next unless d.kind == :constant
|
|
112
|
+
acc[d.name] = acc[d.name] || non_test_reference_paths(d).any?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
90
116
|
# The runtime classification for this definition, joined on the same path +
|
|
91
117
|
# span that make up its symbol_id. nil when no coverage was supplied.
|
|
92
118
|
def runtime_for(definition, coverage)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
module Formatters
|
|
7
|
+
# Emits the typed cycles JSON contract (schema/cycles.schema.json). Renders
|
|
8
|
+
# straight from {CyclesReport#to_h} so the serialized shape cannot drift
|
|
9
|
+
# from the result model.
|
|
10
|
+
module CyclesJson
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param report [CyclesReport]
|
|
14
|
+
# @return [String] pretty-printed JSON
|
|
15
|
+
def render(report)
|
|
16
|
+
JSON.pretty_generate(report.to_h)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
module Formatters
|
|
5
|
+
# Human-readable table of circular file dependencies. Renders from the same
|
|
6
|
+
# {CyclesReport} as the JSON formatter so the two cannot disagree. Sorting
|
|
7
|
+
# already happened in {Cycles}; this layer owns column formatting only.
|
|
8
|
+
module CyclesTable
|
|
9
|
+
MAX_FILES = 3
|
|
10
|
+
RIGHT_ALIGNED = [0, 1].freeze # CONF, SIZE
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param report [CyclesReport]
|
|
15
|
+
# @return [String]
|
|
16
|
+
def render(report)
|
|
17
|
+
findings = report.findings
|
|
18
|
+
return "No cycles found." if findings.empty?
|
|
19
|
+
|
|
20
|
+
headers = %w[CONF SIZE CYCLE FILES]
|
|
21
|
+
rows = findings.map { |f| row(f) }
|
|
22
|
+
[heading(findings.size), "", TextTable.render(headers, rows, right_aligned: RIGHT_ALIGNED)].join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def heading(count)
|
|
26
|
+
"Circular file dependencies (constant-resolved): #{count} cycles"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def row(finding)
|
|
30
|
+
[
|
|
31
|
+
format("%.2f", finding.confidence),
|
|
32
|
+
finding.size.to_s,
|
|
33
|
+
finding.cycle_group,
|
|
34
|
+
chain(finding.files)
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# "a.rb -> b.rb -> a.rb" for small cycles; "a.rb -> b.rb -> c.rb (+2 more)"
|
|
39
|
+
# once the membership no longer fits.
|
|
40
|
+
def chain(files)
|
|
41
|
+
shown = files.first(MAX_FILES)
|
|
42
|
+
extra = files.size - shown.size
|
|
43
|
+
return "#{shown.join(" -> ")} (+#{extra} more)" if extra.positive?
|
|
44
|
+
"#{shown.join(" -> ")} -> #{files.first}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -6,9 +6,9 @@ module Moult
|
|
|
6
6
|
# formatter, so the two cannot disagree. Sorting already happened in
|
|
7
7
|
# {Scoring}; this layer owns limiting and column formatting only.
|
|
8
8
|
module Table
|
|
9
|
-
HEADERS = ["#", "SCORE", "COMPLEXITY", "CHURN", "FILE", "WORST METHOD"].freeze
|
|
9
|
+
HEADERS = ["#", "SCORE", "COMPLEXITY", "CHURN", "IN", "OUT", "INST", "FILE", "WORST METHOD"].freeze
|
|
10
10
|
# Right-align the numeric columns; left-align file and method.
|
|
11
|
-
RIGHT_ALIGNED = [0, 1, 2, 3].freeze
|
|
11
|
+
RIGHT_ALIGNED = [0, 1, 2, 3, 4, 5, 6].freeze
|
|
12
12
|
GUTTER = " "
|
|
13
13
|
|
|
14
14
|
module_function
|
|
@@ -40,6 +40,9 @@ module Moult
|
|
|
40
40
|
num(hotspot.score),
|
|
41
41
|
num(hotspot.complexity),
|
|
42
42
|
hotspot.churn.to_s,
|
|
43
|
+
hotspot.fan_in ? hotspot.fan_in.to_s : "-",
|
|
44
|
+
hotspot.fan_out ? hotspot.fan_out.to_s : "-",
|
|
45
|
+
hotspot.instability ? format("%.2f", hotspot.instability) : "-",
|
|
43
46
|
hotspot.path,
|
|
44
47
|
worst_cell
|
|
45
48
|
]
|
data/lib/moult/index.rb
CHANGED
|
@@ -30,12 +30,24 @@ module Moult
|
|
|
30
30
|
# @!attribute reference_paths [Array<String>] root-relative paths that use it
|
|
31
31
|
# @!attribute override_of [String, nil] FQ name of the ancestor whose method
|
|
32
32
|
# this overrides/implements (reachable via that interface), else nil
|
|
33
|
+
# @!attribute owner_hierarchy_reference_paths [Array<String>, nil] for
|
|
34
|
+
# methods: root-relative paths referencing the owner type or any
|
|
35
|
+
# descendant, excluding the hierarchy's own definition files. nil when
|
|
36
|
+
# the owner is unknown or defined outside the workspace (fact
|
|
37
|
+
# unavailable), and always nil for constants (their own reference_paths
|
|
38
|
+
# already carry the signal)
|
|
33
39
|
Definition = Struct.new(
|
|
34
40
|
:symbol_id, :kind, :name, :unqualified_name, :owner,
|
|
35
41
|
:visibility, :singleton, :span, :path,
|
|
36
|
-
:reference_count, :reference_paths, :override_of
|
|
42
|
+
:reference_count, :reference_paths, :override_of,
|
|
43
|
+
:owner_hierarchy_reference_paths
|
|
37
44
|
)
|
|
38
45
|
|
|
46
|
+
# One resolved constant-reference dependency between two workspace files.
|
|
47
|
+
# +constant+/+span+ identify a representative reference site (the earliest
|
|
48
|
+
# in +src+), kept as evidence rather than every duplicate site.
|
|
49
|
+
Edge = Struct.new(:src, :dst, :constant, :span)
|
|
50
|
+
|
|
39
51
|
BUILTIN_SCHEME = "file:"
|
|
40
52
|
|
|
41
53
|
class << self
|
|
@@ -88,6 +100,33 @@ module Moult
|
|
|
88
100
|
[]
|
|
89
101
|
end
|
|
90
102
|
|
|
103
|
+
# @return [Array<Index::Edge>] unique src->dst file dependencies from
|
|
104
|
+
# resolved constant references (superclass and mixin clauses flow through
|
|
105
|
+
# the same list, so inheritance edges are included). A constant reopened
|
|
106
|
+
# in N files yields an edge to every in-workspace definition file;
|
|
107
|
+
# self-edges and qualifier segments are dropped. Sorted by [src, dst] so
|
|
108
|
+
# output is byte-stable regardless of rubydex iteration order.
|
|
109
|
+
def file_edges
|
|
110
|
+
@file_edges ||= begin
|
|
111
|
+
edges = {}
|
|
112
|
+
resolved = @graph.constant_references.select { |r| r.is_a?(Rubydex::ResolvedConstantReference) }
|
|
113
|
+
qualifiers = qualifier_references(resolved)
|
|
114
|
+
resolved.each do |ref|
|
|
115
|
+
next if qualifiers.include?(ref)
|
|
116
|
+
src = workspace_relative(ref.location)
|
|
117
|
+
next unless src
|
|
118
|
+
span = span_from(ref.location)
|
|
119
|
+
in_workspace_definitions(ref.declaration).each do |_defn, _span, dst|
|
|
120
|
+
next if dst == src
|
|
121
|
+
existing = edges[[src, dst]]
|
|
122
|
+
next if existing && existing.span.start_line <= span.start_line
|
|
123
|
+
edges[[src, dst]] = Edge.new(src: src, dst: dst, constant: ref.declaration.name, span: span)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
edges.values.sort_by { |e| [e.src, e.dst] }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
91
130
|
private
|
|
92
131
|
|
|
93
132
|
def method_definitions
|
|
@@ -96,6 +135,7 @@ module Moult
|
|
|
96
135
|
unqualified = strip_signature(decl.unqualified_name)
|
|
97
136
|
sites = method_call_sites[unqualified]
|
|
98
137
|
override = override_source(decl)
|
|
138
|
+
hierarchy_refs = hierarchy_reference_paths(decl.owner)
|
|
99
139
|
in_workspace_definitions(decl).map do |defn, span, rel|
|
|
100
140
|
Definition.new(
|
|
101
141
|
symbol_id: SymbolId.for(path: rel, start_line: span.start_line, fqname: name),
|
|
@@ -109,7 +149,8 @@ module Moult
|
|
|
109
149
|
path: rel,
|
|
110
150
|
reference_count: sites.size,
|
|
111
151
|
reference_paths: sites.compact.uniq,
|
|
112
|
-
override_of: override
|
|
152
|
+
override_of: override,
|
|
153
|
+
owner_hierarchy_reference_paths: hierarchy_refs
|
|
113
154
|
)
|
|
114
155
|
end
|
|
115
156
|
end
|
|
@@ -160,6 +201,26 @@ module Moult
|
|
|
160
201
|
end
|
|
161
202
|
end
|
|
162
203
|
|
|
204
|
+
# The qualifier segments of constant paths. In `Moult::Error`, rubydex
|
|
205
|
+
# records a resolved reference for the `Moult` token as well as one for
|
|
206
|
+
# `Error` — but the file dependency is on where *Error* is defined. A root
|
|
207
|
+
# namespace reopened in every file (`module Moult` in each) would otherwise
|
|
208
|
+
# fan edges out to the entire codebase, drowning the graph. A reference is
|
|
209
|
+
# a qualifier when a same-line reference to a deeper constant in its own
|
|
210
|
+
# namespace starts exactly two columns (the `::`) after it ends.
|
|
211
|
+
def qualifier_references(resolved)
|
|
212
|
+
by_line = resolved.group_by { |r| [r.location&.uri, r.location&.start_line] }
|
|
213
|
+
resolved.each_with_object(Set.new) do |ref, quals|
|
|
214
|
+
loc = ref.location
|
|
215
|
+
next unless loc
|
|
216
|
+
deeper = "#{ref.declaration.name}::"
|
|
217
|
+
quals << ref if by_line[[loc.uri, loc.start_line]].any? do |other|
|
|
218
|
+
other.declaration.name.start_with?(deeper) &&
|
|
219
|
+
other.location.start_column == loc.end_column + 2
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
163
224
|
def in_workspace?(location)
|
|
164
225
|
return false unless location
|
|
165
226
|
uri = location.uri
|
|
@@ -210,6 +271,33 @@ module Moult
|
|
|
210
271
|
nil
|
|
211
272
|
end
|
|
212
273
|
|
|
274
|
+
# Production-reachability of a method's receiver type: the root-relative
|
|
275
|
+
# paths of constant references to the owner namespace or any descendant,
|
|
276
|
+
# minus the hierarchy's own definition files — a subclass's `< Base`
|
|
277
|
+
# clause is itself a reference to Base and would otherwise make the result
|
|
278
|
+
# never empty. nil when the owner is unknown or defined outside the
|
|
279
|
+
# workspace (Object, gems): the fact is unavailable, not "unreferenced".
|
|
280
|
+
# Cached per owner so one descendants walk serves all of its methods.
|
|
281
|
+
def hierarchy_reference_paths(owner)
|
|
282
|
+
return nil unless owner
|
|
283
|
+
@hierarchy_refs ||= {}
|
|
284
|
+
return @hierarchy_refs[owner.name] if @hierarchy_refs.key?(owner.name)
|
|
285
|
+
@hierarchy_refs[owner.name] = begin
|
|
286
|
+
# def self.x lives on the singleton class; judge the class itself.
|
|
287
|
+
target = owner.is_a?(Rubydex::SingletonClass) ? owner.attached_class : owner
|
|
288
|
+
if target.is_a?(Rubydex::Namespace)
|
|
289
|
+
namespaces = ([target] + target.descendants.to_a).uniq(&:name)
|
|
290
|
+
own_files = namespaces.flat_map { |ns| in_workspace_definitions(ns).map { |_defn, _span, rel| rel } }.uniq
|
|
291
|
+
unless own_files.empty?
|
|
292
|
+
namespaces.flat_map { |ns| ns.references.map { |ref| workspace_relative(ref.location) } }
|
|
293
|
+
.compact.uniq - own_files
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
rescue
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
213
301
|
def visibility_of(decl)
|
|
214
302
|
vis = decl.visibility if decl.respond_to?(:visibility)
|
|
215
303
|
%i[public private protected].include?(vis) ? vis : :public
|
data/lib/moult/report.rb
CHANGED
|
@@ -47,19 +47,29 @@ module Moult
|
|
|
47
47
|
|
|
48
48
|
# One ranked file.
|
|
49
49
|
class Hotspot
|
|
50
|
-
attr_reader :path, :score, :complexity, :churn, :methods,
|
|
50
|
+
attr_reader :path, :score, :complexity, :churn, :methods,
|
|
51
|
+
:fan_in, :fan_out, :instability, :confidence, :category
|
|
51
52
|
|
|
52
53
|
# @param path [String] path relative to the analysis root
|
|
53
54
|
# @param score [Float] complexity x churn
|
|
54
55
|
# @param complexity [Float] aggregate ABC for the file
|
|
55
56
|
# @param churn [Integer] commits touching the file in the window
|
|
56
57
|
# @param methods [Array<Method>] worst methods, highest ABC first
|
|
57
|
-
|
|
58
|
+
# @param fan_in [Integer, nil] files depending on this file (resolved
|
|
59
|
+
# constant references); nil when no index was built
|
|
60
|
+
# @param fan_out [Integer, nil] files this file depends on; nil likewise
|
|
61
|
+
# @param instability [Float, nil] fan_out / (fan_in + fan_out); 0 when
|
|
62
|
+
# isolated, nil when no index was built
|
|
63
|
+
def initialize(path:, score:, complexity:, churn:, methods:,
|
|
64
|
+
fan_in: nil, fan_out: nil, instability: nil, confidence: nil, category: nil)
|
|
58
65
|
@path = path
|
|
59
66
|
@score = score
|
|
60
67
|
@complexity = complexity
|
|
61
68
|
@churn = churn
|
|
62
69
|
@methods = methods
|
|
70
|
+
@fan_in = fan_in
|
|
71
|
+
@fan_out = fan_out
|
|
72
|
+
@instability = instability
|
|
63
73
|
@confidence = confidence
|
|
64
74
|
@category = category
|
|
65
75
|
end
|
|
@@ -75,6 +85,9 @@ module Moult
|
|
|
75
85
|
score: score,
|
|
76
86
|
complexity: complexity,
|
|
77
87
|
churn: churn,
|
|
88
|
+
fan_in: fan_in,
|
|
89
|
+
fan_out: fan_out,
|
|
90
|
+
instability: instability,
|
|
78
91
|
confidence: confidence,
|
|
79
92
|
category: category,
|
|
80
93
|
methods: methods.map(&:to_h)
|
data/lib/moult/scoring.rb
CHANGED
|
@@ -23,11 +23,14 @@ module Moult
|
|
|
23
23
|
# @param files [Array<String>] absolute paths of Ruby files to analyse
|
|
24
24
|
# @param churn [Hash{String=>Integer}] path (relative to root) => commit count
|
|
25
25
|
# @param worst_methods [Integer] how many worst methods to keep per file
|
|
26
|
+
# @param edges [Array<#src,#dst>, nil] unique file dependency pairs (e.g.
|
|
27
|
+
# {Index#file_edges}); nil leaves the coupling columns null
|
|
26
28
|
# @return [Report]
|
|
27
29
|
def build_report(root:, files:, churn:, worst_methods: DEFAULT_WORST_METHODS,
|
|
28
|
-
git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil)
|
|
30
|
+
git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil, edges: nil)
|
|
31
|
+
coupling = coupling_degrees(edges)
|
|
29
32
|
hotspots = files.filter_map do |abs|
|
|
30
|
-
hotspot_for(abs, root: root, churn: churn, worst_methods: worst_methods)
|
|
33
|
+
hotspot_for(abs, root: root, churn: churn, worst_methods: worst_methods, coupling: coupling)
|
|
31
34
|
end
|
|
32
35
|
hotspots.sort_by! { |h| [-h.score, -h.complexity, h.path] }
|
|
33
36
|
|
|
@@ -42,7 +45,7 @@ module Moult
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
# @return [Report::Hotspot, nil] nil when the file has no scoring methods
|
|
45
|
-
def hotspot_for(abs, root:, churn:, worst_methods:)
|
|
48
|
+
def hotspot_for(abs, root:, churn:, worst_methods:, coupling: nil)
|
|
46
49
|
rel = relative_path(abs, root)
|
|
47
50
|
methods = Parser.parse_file(abs).map { |m| build_method(m, rel) }
|
|
48
51
|
complexity = methods.sum(0.0, &:abc)
|
|
@@ -56,16 +59,43 @@ module Moult
|
|
|
56
59
|
score: combine(complexity, churn_count).round(2),
|
|
57
60
|
complexity: complexity.round(2),
|
|
58
61
|
churn: churn_count,
|
|
59
|
-
methods: kept
|
|
62
|
+
methods: kept,
|
|
63
|
+
**coupling_fields(rel, coupling)
|
|
60
64
|
)
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
# The v0.1 scoring rule. Swap-point for future normalisation.
|
|
67
|
+
# The v0.1 scoring rule. Swap-point for future normalisation. Coupling is
|
|
68
|
+
# deliberately not a factor: weighting by instability would bury complex,
|
|
69
|
+
# churned, high-fan-in files — exactly the hotspots that matter most.
|
|
64
70
|
# @return [Numeric]
|
|
65
71
|
def combine(complexity, churn)
|
|
66
72
|
complexity * churn
|
|
67
73
|
end
|
|
68
74
|
|
|
75
|
+
# In/out degree per file over the unique dependency pairs. nil when no
|
|
76
|
+
# edges were supplied (coupling unknown).
|
|
77
|
+
def coupling_degrees(edges)
|
|
78
|
+
return nil unless edges
|
|
79
|
+
{
|
|
80
|
+
out: edges.group_by(&:src).transform_values(&:size),
|
|
81
|
+
in: edges.group_by(&:dst).transform_values(&:size)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A file absent from the edge set is measured-as-isolated (0/0), not
|
|
86
|
+
# unknown: the index saw it and found no resolved dependencies.
|
|
87
|
+
def coupling_fields(rel, coupling)
|
|
88
|
+
return {fan_in: nil, fan_out: nil, instability: nil} unless coupling
|
|
89
|
+
fan_in = coupling[:in][rel] || 0
|
|
90
|
+
fan_out = coupling[:out][rel] || 0
|
|
91
|
+
total = fan_in + fan_out
|
|
92
|
+
{
|
|
93
|
+
fan_in: fan_in,
|
|
94
|
+
fan_out: fan_out,
|
|
95
|
+
instability: total.zero? ? 0.0 : (fan_out.to_f / total).round(2)
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
69
99
|
def build_method(method_def, rel)
|
|
70
100
|
Report::Method.new(
|
|
71
101
|
symbol_id: SymbolId.for(path: rel, start_line: method_def.span.start_line, fqname: method_def.name),
|
data/lib/moult/version.rb
CHANGED
data/lib/moult.rb
CHANGED
|
@@ -38,6 +38,10 @@ module Moult
|
|
|
38
38
|
autoload :Duplication, "moult/duplication"
|
|
39
39
|
autoload :DuplicationReport, "moult/duplication_report"
|
|
40
40
|
|
|
41
|
+
# Static slice: circular file dependencies over resolved constant references.
|
|
42
|
+
autoload :Cycles, "moult/cycles"
|
|
43
|
+
autoload :CyclesReport, "moult/cycles_report"
|
|
44
|
+
|
|
41
45
|
# Health slice: a composite health score aggregating the other analyses.
|
|
42
46
|
autoload :Health, "moult/health"
|
|
43
47
|
autoload :HealthReport, "moult/health_report"
|
|
@@ -69,6 +73,8 @@ module Moult
|
|
|
69
73
|
autoload :CoverageJson, "moult/formatters/coverage_json"
|
|
70
74
|
autoload :DuplicationTable, "moult/formatters/duplication_table"
|
|
71
75
|
autoload :DuplicationJson, "moult/formatters/duplication_json"
|
|
76
|
+
autoload :CyclesTable, "moult/formatters/cycles_table"
|
|
77
|
+
autoload :CyclesJson, "moult/formatters/cycles_json"
|
|
72
78
|
autoload :HealthTable, "moult/formatters/health_table"
|
|
73
79
|
autoload :HealthJson, "moult/formatters/health_json"
|
|
74
80
|
autoload :BoundariesTable, "moult/formatters/boundaries_table"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/moult-rb/moult-rb/blob/main/schema/cycles.schema.json",
|
|
4
|
+
"title": "Moult cycles report",
|
|
5
|
+
"description": "Typed output contract for `moult cycles`. Each finding is one strongly-connected component of the file dependency graph built from resolved constant references, carrying its evidence edges; nothing here asserts that a cycle is certainly harmful.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "tool", "analysis", "summary", "findings"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {
|
|
11
|
+
"description": "Bumped only on a breaking change to this shape.",
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"const": 1
|
|
14
|
+
},
|
|
15
|
+
"tool": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["name", "version"],
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": {"type": "string"},
|
|
21
|
+
"version": {"type": "string"}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"analysis": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["root", "git_ref", "generated_at", "index"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"root": {"type": "string"},
|
|
30
|
+
"git_ref": {"type": ["string", "null"]},
|
|
31
|
+
"generated_at": {"type": ["string", "null"], "format": "date-time"},
|
|
32
|
+
"index": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"required": ["backend", "backend_version", "resolved", "diagnostics"],
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"properties": {
|
|
37
|
+
"backend": {"type": "string"},
|
|
38
|
+
"backend_version": {"type": ["string", "null"]},
|
|
39
|
+
"resolved": {"type": "boolean"},
|
|
40
|
+
"diagnostics": {"type": "array", "items": {"type": "string"}}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"summary": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"required": ["cycles", "files", "largest"],
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"properties": {
|
|
50
|
+
"cycles": {"description": "Number of circular dependency groups.", "type": "integer", "minimum": 0},
|
|
51
|
+
"files": {"description": "Total files across all cycles.", "type": "integer", "minimum": 0},
|
|
52
|
+
"largest": {"description": "File count of the largest cycle; 0 when none.", "type": "integer", "minimum": 0}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"findings": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"items": {"$ref": "#/$defs/finding"}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"$defs": {
|
|
61
|
+
"reason": {
|
|
62
|
+
"description": "One auditable contribution to a finding's confidence.",
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["rule", "delta", "detail"],
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"properties": {
|
|
67
|
+
"rule": {"type": "string"},
|
|
68
|
+
"delta": {"description": "Signed adjustment this rule applied.", "type": "number"},
|
|
69
|
+
"detail": {"type": "string"}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"edge": {
|
|
73
|
+
"description": "One in-cycle file dependency, evidenced by a representative resolved constant-reference site in the source file.",
|
|
74
|
+
"type": "object",
|
|
75
|
+
"required": ["src", "dst", "constant", "span"],
|
|
76
|
+
"additionalProperties": false,
|
|
77
|
+
"properties": {
|
|
78
|
+
"src": {"description": "Root-relative path of the referencing file.", "type": "string"},
|
|
79
|
+
"dst": {"description": "Root-relative path of the file defining the referenced constant.", "type": "string"},
|
|
80
|
+
"constant": {"description": "Fully-qualified name of the referenced constant.", "type": "string"},
|
|
81
|
+
"span": {"$ref": "common.schema.json#/$defs/span"}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"finding": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"required": ["cycle_group", "category", "confidence", "size", "files", "reasons", "edges"],
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"properties": {
|
|
89
|
+
"cycle_group": {
|
|
90
|
+
"description": "Cycle join key, \"scc:<hash of the sorted member files>\". Membership-stable across runs and machines: the same set of files is the same cycle, however its edges shift.",
|
|
91
|
+
"type": "string",
|
|
92
|
+
"pattern": "^scc:"
|
|
93
|
+
},
|
|
94
|
+
"category": {
|
|
95
|
+
"description": "Always \"cycle\" in this contract.",
|
|
96
|
+
"type": "string"
|
|
97
|
+
},
|
|
98
|
+
"confidence": {
|
|
99
|
+
"description": "Likelihood this is a genuine dependency cycle, in [0, 1]. High (edges are resolved constant references) but never 1: reopened constants can widen a cycle.",
|
|
100
|
+
"$ref": "common.schema.json#/$defs/unit_interval"
|
|
101
|
+
},
|
|
102
|
+
"size": {
|
|
103
|
+
"description": "Number of files in the cycle.",
|
|
104
|
+
"type": "integer",
|
|
105
|
+
"minimum": 2
|
|
106
|
+
},
|
|
107
|
+
"files": {
|
|
108
|
+
"description": "Sorted root-relative paths of the cycle's members.",
|
|
109
|
+
"type": "array",
|
|
110
|
+
"minItems": 2,
|
|
111
|
+
"items": {"type": "string"}
|
|
112
|
+
},
|
|
113
|
+
"reasons": {
|
|
114
|
+
"description": "The factors behind the confidence, in application order.",
|
|
115
|
+
"type": "array",
|
|
116
|
+
"items": {"$ref": "#/$defs/reason"}
|
|
117
|
+
},
|
|
118
|
+
"edges": {
|
|
119
|
+
"description": "The in-cycle dependencies; a cycle has at least two.",
|
|
120
|
+
"type": "array",
|
|
121
|
+
"minItems": 2,
|
|
122
|
+
"items": {"$ref": "#/$defs/edge"}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
data/schema/hotspots.schema.json
CHANGED
|
@@ -93,6 +93,23 @@
|
|
|
93
93
|
"type": "integer",
|
|
94
94
|
"minimum": 0
|
|
95
95
|
},
|
|
96
|
+
"fan_in": {
|
|
97
|
+
"description": "Files depending on this file via resolved constant references. Null when no index was built (--no-coupling or index failure).",
|
|
98
|
+
"type": ["integer", "null"],
|
|
99
|
+
"minimum": 0
|
|
100
|
+
},
|
|
101
|
+
"fan_out": {
|
|
102
|
+
"description": "Files this file depends on via resolved constant references. Null when no index was built.",
|
|
103
|
+
"type": ["integer", "null"],
|
|
104
|
+
"minimum": 0
|
|
105
|
+
},
|
|
106
|
+
"instability": {
|
|
107
|
+
"description": "fan_out / (fan_in + fan_out); 0 when the file is isolated. Null when no index was built.",
|
|
108
|
+
"oneOf": [
|
|
109
|
+
{"type": "null"},
|
|
110
|
+
{"$ref": "common.schema.json#/$defs/unit_interval"}
|
|
111
|
+
]
|
|
112
|
+
},
|
|
96
113
|
"methods": {
|
|
97
114
|
"description": "Worst methods in the file, highest ABC first.",
|
|
98
115
|
"type": "array",
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: moult
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brandyn Britton
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- lib/moult/cli.rb
|
|
80
80
|
- lib/moult/cli/boundaries_command.rb
|
|
81
81
|
- lib/moult/cli/coverage_command.rb
|
|
82
|
+
- lib/moult/cli/cycles_command.rb
|
|
82
83
|
- lib/moult/cli/dead_code_command.rb
|
|
83
84
|
- lib/moult/cli/duplication_command.rb
|
|
84
85
|
- lib/moult/cli/flags_command.rb
|
|
@@ -92,6 +93,8 @@ files:
|
|
|
92
93
|
- lib/moult/coverage.rb
|
|
93
94
|
- lib/moult/coverage/resolver.rb
|
|
94
95
|
- lib/moult/coverage_report.rb
|
|
96
|
+
- lib/moult/cycles.rb
|
|
97
|
+
- lib/moult/cycles_report.rb
|
|
95
98
|
- lib/moult/dead_code.rb
|
|
96
99
|
- lib/moult/dead_code_report.rb
|
|
97
100
|
- lib/moult/diff.rb
|
|
@@ -109,6 +112,8 @@ files:
|
|
|
109
112
|
- lib/moult/formatters/boundaries_table.rb
|
|
110
113
|
- lib/moult/formatters/coverage_json.rb
|
|
111
114
|
- lib/moult/formatters/coverage_table.rb
|
|
115
|
+
- lib/moult/formatters/cycles_json.rb
|
|
116
|
+
- lib/moult/formatters/cycles_table.rb
|
|
112
117
|
- lib/moult/formatters/dead_code_json.rb
|
|
113
118
|
- lib/moult/formatters/dead_code_table.rb
|
|
114
119
|
- lib/moult/formatters/duplication_json.rb
|
|
@@ -146,6 +151,7 @@ files:
|
|
|
146
151
|
- schema/boundaries.schema.json
|
|
147
152
|
- schema/common.schema.json
|
|
148
153
|
- schema/coverage.schema.json
|
|
154
|
+
- schema/cycles.schema.json
|
|
149
155
|
- schema/deadcode.schema.json
|
|
150
156
|
- schema/duplication.schema.json
|
|
151
157
|
- schema/flags.schema.json
|