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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ad87ca8d76d09c7808a172aa24b42bf8c2031c4c5e05604f1c98e2a1120aacc
4
- data.tar.gz: 683a307fa930966927516e37f6b1445b7b0e08c31da996d2c250d8ace63b2e67
3
+ metadata.gz: 59531e8f248b8da451cabb11f828b81fb25e2c16d6226830eb90321a43ba26f1
4
+ data.tar.gz: c00350cef7dc2e9618b7fef715cc985e6fc708de655978bd76e0b15296f72c92
5
5
  SHA512:
6
- metadata.gz: 04d0d5fb568b8135a854d36a89e4b1134a8666007e033465f7e5d56c7566e80dce839a383b333c4dad72cb02054b4b5e87b88954003f8a8d29555e545d5d276f
7
- data.tar.gz: 82836397a09f0314f00ae108c339c2a119beb9c0a648323805bbd1a1d9c87b2bbf8191c94e6815837fa1627eb535d9e7c2511df21e8e12ea78ca570ec564dd61
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); a file that uses
208
- `send`/`define_method`/`method_missing`/`const_get`/`eval`.
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? },
@@ -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 # Symbol, nil: :hot/:cold/:untracked from coverage (Phase 3)
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.
@@ -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
@@ -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, coverage: coverage))
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, :confidence, :category
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
- def initialize(path:, score:, complexity:, churn:, methods:, confidence: nil, category: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moult
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
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
+ }
@@ -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.1
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-05 00:00:00.000000000 Z
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