moult 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +4 -0
- data/README.md +331 -0
- data/exe/moult +6 -0
- data/lib/moult/abc.rb +133 -0
- data/lib/moult/boundaries/packwerk.rb +114 -0
- data/lib/moult/boundaries/severity.rb +87 -0
- data/lib/moult/boundaries.rb +77 -0
- data/lib/moult/boundaries_report.rb +106 -0
- data/lib/moult/churn.rb +52 -0
- data/lib/moult/cli/boundaries_command.rb +83 -0
- data/lib/moult/cli/coverage_command.rb +101 -0
- data/lib/moult/cli/dead_code_command.rb +112 -0
- data/lib/moult/cli/duplication_command.rb +92 -0
- data/lib/moult/cli/flags_command.rb +95 -0
- data/lib/moult/cli/gate_command.rb +113 -0
- data/lib/moult/cli/health_command.rb +117 -0
- data/lib/moult/cli/hotspots_command.rb +104 -0
- data/lib/moult/cli.rb +102 -0
- data/lib/moult/clones.rb +91 -0
- data/lib/moult/cloud_upload.rb +29 -0
- data/lib/moult/confidence/rules.rb +128 -0
- data/lib/moult/confidence.rb +106 -0
- data/lib/moult/coverage/resolver.rb +56 -0
- data/lib/moult/coverage.rb +176 -0
- data/lib/moult/coverage_report.rb +98 -0
- data/lib/moult/dead_code.rb +119 -0
- data/lib/moult/dead_code_report.rb +65 -0
- data/lib/moult/diff.rb +177 -0
- data/lib/moult/discovery.rb +38 -0
- data/lib/moult/duplication/confidence.rb +92 -0
- data/lib/moult/duplication.rb +112 -0
- data/lib/moult/duplication_report.rb +89 -0
- data/lib/moult/flag_scanner.rb +150 -0
- data/lib/moult/flags/classification.rb +79 -0
- data/lib/moult/flags/snapshot.rb +162 -0
- data/lib/moult/flags/staleness.rb +145 -0
- data/lib/moult/flags.rb +131 -0
- data/lib/moult/flags_report.rb +136 -0
- data/lib/moult/formatters/boundaries_json.rb +20 -0
- data/lib/moult/formatters/boundaries_table.rb +53 -0
- data/lib/moult/formatters/coverage_json.rb +19 -0
- data/lib/moult/formatters/coverage_table.rb +60 -0
- data/lib/moult/formatters/dead_code_json.rb +20 -0
- data/lib/moult/formatters/dead_code_table.rb +66 -0
- data/lib/moult/formatters/duplication_json.rb +20 -0
- data/lib/moult/formatters/duplication_table.rb +55 -0
- data/lib/moult/formatters/flags_json.rb +20 -0
- data/lib/moult/formatters/flags_table.rb +76 -0
- data/lib/moult/formatters/gate_github.rb +52 -0
- data/lib/moult/formatters/gate_json.rb +20 -0
- data/lib/moult/formatters/gate_message.rb +19 -0
- data/lib/moult/formatters/gate_sarif.rb +78 -0
- data/lib/moult/formatters/gate_table.rb +71 -0
- data/lib/moult/formatters/health_json.rb +20 -0
- data/lib/moult/formatters/health_table.rb +80 -0
- data/lib/moult/formatters/json.rb +23 -0
- data/lib/moult/formatters/table.rb +70 -0
- data/lib/moult/formatters/text_table.rb +39 -0
- data/lib/moult/gate/config.rb +55 -0
- data/lib/moult/gate/evaluation.rb +172 -0
- data/lib/moult/gate/policy.rb +103 -0
- data/lib/moult/gate.rb +199 -0
- data/lib/moult/gate_report.rb +97 -0
- data/lib/moult/git.rb +83 -0
- data/lib/moult/health/score.rb +291 -0
- data/lib/moult/health.rb +320 -0
- data/lib/moult/health_report.rb +97 -0
- data/lib/moult/index.rb +228 -0
- data/lib/moult/parser.rb +101 -0
- data/lib/moult/rails_conventions.rb +124 -0
- data/lib/moult/report.rb +114 -0
- data/lib/moult/scoring.rb +82 -0
- data/lib/moult/span.rb +17 -0
- data/lib/moult/symbol_id.rb +30 -0
- data/lib/moult/symbol_scanner.rb +100 -0
- data/lib/moult/version.rb +5 -0
- data/lib/moult.rb +84 -0
- data/schema/boundaries.schema.json +125 -0
- data/schema/common.schema.json +76 -0
- data/schema/coverage.schema.json +83 -0
- data/schema/deadcode.schema.json +106 -0
- data/schema/duplication.schema.json +128 -0
- data/schema/flags.schema.json +157 -0
- data/schema/gate.schema.json +165 -0
- data/schema/health.schema.json +157 -0
- data/schema/hotspots.schema.json +106 -0
- metadata +185 -0
data/lib/moult/abc.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# Flog-style weighted ABC complexity for a single method.
|
|
7
|
+
#
|
|
8
|
+
# This is *not* the bare ABC metric (sqrt(A^2 + B^2 + C^2)). Following flog,
|
|
9
|
+
# the score is a weighted *sum* of three buckets, with metaprogramming calls
|
|
10
|
+
# penalised and a compounding depth penalty for nesting:
|
|
11
|
+
#
|
|
12
|
+
# * A - Assignments: any write node (`=`, op-assign, `||=`, multi-assign,
|
|
13
|
+
# `obj.x =`, `arr[i] =`). Weight {ASSIGNMENT}. Counted once per write node.
|
|
14
|
+
# * B - Branches: every message send ({Prism::CallNode}, including operators
|
|
15
|
+
# like `+` and `==` and index `[]`), plus `yield` and `super`. Weight
|
|
16
|
+
# {BRANCH}, except metaprogramming calls in {MAGIC_CALLS}, which weigh more.
|
|
17
|
+
# * C - Conditions: decision nodes - if/unless/while/until/for, case + each
|
|
18
|
+
# when/in, rescue, and `&&`/`||`. Weight {CONDITION}.
|
|
19
|
+
#
|
|
20
|
+
# Depth penalty: contributions nested inside a control structure or block are
|
|
21
|
+
# multiplied by {DEPTH_FACTOR} per level, compounding. A call directly in the
|
|
22
|
+
# method body weighs 1.0; the same call one `if` deep weighs 1.1; two deep,
|
|
23
|
+
# 1.21; and so on.
|
|
24
|
+
#
|
|
25
|
+
# flog is the reference for the *shape* of this metric; the exact weights below
|
|
26
|
+
# are the ones Moult adopts and are pinned by hand-counted fixtures. Treat any
|
|
27
|
+
# drift from those fixtures as a metric bug.
|
|
28
|
+
module ABC
|
|
29
|
+
ASSIGNMENT = 1.0
|
|
30
|
+
BRANCH = 1.0
|
|
31
|
+
CONDITION = 1.0
|
|
32
|
+
|
|
33
|
+
# Each level of control-flow / block nesting compounds contributions by 10%.
|
|
34
|
+
DEPTH_FACTOR = 1.1
|
|
35
|
+
|
|
36
|
+
# Metaprogramming and dynamic-dispatch calls weigh more than an ordinary
|
|
37
|
+
# send, mirroring flog's penalties for hard-to-follow Ruby.
|
|
38
|
+
MAGIC_CALLS = {
|
|
39
|
+
eval: 5.0,
|
|
40
|
+
instance_eval: 5.0,
|
|
41
|
+
class_eval: 5.0,
|
|
42
|
+
module_eval: 5.0,
|
|
43
|
+
class_exec: 5.0,
|
|
44
|
+
instance_exec: 5.0,
|
|
45
|
+
define_method: 4.0,
|
|
46
|
+
define_singleton_method: 4.0,
|
|
47
|
+
method_missing: 4.0,
|
|
48
|
+
alias_method: 2.0,
|
|
49
|
+
send: 3.0,
|
|
50
|
+
__send__: 3.0,
|
|
51
|
+
public_send: 3.0
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
BRANCH_NODES = [
|
|
55
|
+
Prism::CallNode,
|
|
56
|
+
Prism::YieldNode,
|
|
57
|
+
Prism::SuperNode,
|
|
58
|
+
Prism::ForwardingSuperNode
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
CONDITION_NODES = [
|
|
62
|
+
Prism::IfNode,
|
|
63
|
+
Prism::UnlessNode,
|
|
64
|
+
Prism::WhileNode,
|
|
65
|
+
Prism::UntilNode,
|
|
66
|
+
Prism::ForNode,
|
|
67
|
+
Prism::CaseNode,
|
|
68
|
+
Prism::CaseMatchNode,
|
|
69
|
+
Prism::WhenNode,
|
|
70
|
+
Prism::InNode,
|
|
71
|
+
Prism::RescueNode,
|
|
72
|
+
Prism::AndNode,
|
|
73
|
+
Prism::OrNode
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
# Nodes whose children sit one nesting level deeper. Containers only - the
|
|
77
|
+
# when/in/&&/|| conditions don't bump again on top of their container.
|
|
78
|
+
NESTING_NODES = [
|
|
79
|
+
Prism::IfNode,
|
|
80
|
+
Prism::UnlessNode,
|
|
81
|
+
Prism::WhileNode,
|
|
82
|
+
Prism::UntilNode,
|
|
83
|
+
Prism::ForNode,
|
|
84
|
+
Prism::CaseNode,
|
|
85
|
+
Prism::CaseMatchNode,
|
|
86
|
+
Prism::RescueNode,
|
|
87
|
+
Prism::BlockNode,
|
|
88
|
+
Prism::LambdaNode
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
module_function
|
|
92
|
+
|
|
93
|
+
# @param def_node [Prism::DefNode] a method definition
|
|
94
|
+
# @return [Float] the method's weighted ABC score, rounded to 2 decimals
|
|
95
|
+
def score(def_node)
|
|
96
|
+
total = walk(def_node, 1.0, root: true)
|
|
97
|
+
total.round(2)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Recursively accumulate weighted contributions. Nested `def`s are scored
|
|
101
|
+
# independently (they're separate methods), so we don't descend into them.
|
|
102
|
+
def walk(node, multiplier, root: false)
|
|
103
|
+
return 0.0 if node.is_a?(Prism::DefNode) && !root
|
|
104
|
+
|
|
105
|
+
total = weight_for(node) * multiplier
|
|
106
|
+
child_multiplier = NESTING_NODES.include?(node.class) ? multiplier * DEPTH_FACTOR : multiplier
|
|
107
|
+
node.compact_child_nodes.each do |child|
|
|
108
|
+
total += walk(child, child_multiplier)
|
|
109
|
+
end
|
|
110
|
+
total
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The weight this node itself contributes (before the depth multiplier).
|
|
114
|
+
def weight_for(node)
|
|
115
|
+
case node
|
|
116
|
+
when Prism::CallNode
|
|
117
|
+
MAGIC_CALLS.fetch(node.name, BRANCH)
|
|
118
|
+
else
|
|
119
|
+
return BRANCH if BRANCH_NODES.include?(node.class)
|
|
120
|
+
return ASSIGNMENT if assignment?(node)
|
|
121
|
+
return CONDITION if CONDITION_NODES.include?(node.class)
|
|
122
|
+
|
|
123
|
+
0.0
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Every Prism assignment node class ends in "WriteNode" (plain writes,
|
|
128
|
+
# operator writes, ||=/&&= writes, multi-writes, and index/attr writes).
|
|
129
|
+
def assignment?(node)
|
|
130
|
+
node.class.name.end_with?("WriteNode")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "../symbol_id"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
module Boundaries
|
|
8
|
+
# The architecture-boundary adapter — Moult's reader of Packwerk's on-disk
|
|
9
|
+
# artifacts and the *only* file that names Packwerk. Everything downstream
|
|
10
|
+
# consumes the Moult-owned {Violation}/{Result} value objects, never a packwerk
|
|
11
|
+
# type, so the backend is swappable (the "swap, not rewrite" invariant).
|
|
12
|
+
#
|
|
13
|
+
# Like {Coverage} (which ingests SimpleCov/stdlib coverage *files*), this slice
|
|
14
|
+
# ingests packwerk's *files* rather than booting it: a live `bin/packwerk check`
|
|
15
|
+
# needs a bootable Rails/Zeitwerk app and emits only human prose / new-violation
|
|
16
|
+
# deltas, whereas packwerk *serialises every recorded violation* to stable,
|
|
17
|
+
# diffable `package_todo.yml` files. We read those (the package graph + the
|
|
18
|
+
# recorded violations packwerk already resolved via Zeitwerk) and own no part of
|
|
19
|
+
# the constant-resolution graph. Consequently Moult needs NO packwerk gem
|
|
20
|
+
# dependency (exactly as {Coverage} needs no simplecov). Live re-analysis — the
|
|
21
|
+
# fresh, line-level offense set — is deferred, the same way the Coverband and
|
|
22
|
+
# Flipper live stores are.
|
|
23
|
+
#
|
|
24
|
+
# The `package_todo.yml` shape we parse (packwerk's own serialization):
|
|
25
|
+
#
|
|
26
|
+
# <defining-package>: # the package that OWNS the referenced constant
|
|
27
|
+
# "::Some::Constant": # the constant crossing the boundary
|
|
28
|
+
# violations:
|
|
29
|
+
# - dependency # one or more violation types
|
|
30
|
+
# - privacy
|
|
31
|
+
# files:
|
|
32
|
+
# - path/to/referencing.rb # the referencing files (root-relative)
|
|
33
|
+
#
|
|
34
|
+
# The file lives at `<referencing-package-dir>/package_todo.yml`, so the
|
|
35
|
+
# referencing package is the file's directory (root-relative; "." for the root
|
|
36
|
+
# package). packwerk reports violations at FILE granularity (no line numbers),
|
|
37
|
+
# which fixes this slice's join at path level.
|
|
38
|
+
module Packwerk
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# A single recorded boundary violation: one referencing file crossing into one
|
|
42
|
+
# constant owned by another package, of one type. Path is root-relative.
|
|
43
|
+
Violation = Struct.new(:violation_type, :referencing_package, :defining_package, :constant, :path)
|
|
44
|
+
|
|
45
|
+
# The Moult-owned result of reading a project's packwerk artifacts. +configured+
|
|
46
|
+
# is false when the project has no `packwerk.yml` (not a packwerk project), in
|
|
47
|
+
# which case +violations+ is empty. +backend+/+backend_version+ originate here so
|
|
48
|
+
# "packwerk" stays isolated to this file.
|
|
49
|
+
Result = Struct.new(:violations, :backend, :backend_version, :configured)
|
|
50
|
+
|
|
51
|
+
# @param root [String] absolute analysis root
|
|
52
|
+
# @return [Result]
|
|
53
|
+
def detect(root:)
|
|
54
|
+
unless configured?(root)
|
|
55
|
+
return Result.new(violations: [], backend: "packwerk", backend_version: backend_version, configured: false)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
violations = todo_files(root).flat_map { |file| violations_in(file, root) }
|
|
59
|
+
Result.new(violations: violations, backend: "packwerk", backend_version: backend_version, configured: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A `packwerk.yml` at the root is the unambiguous "this is a packwerk project"
|
|
63
|
+
# marker (it is required for any packwerk run).
|
|
64
|
+
def configured?(root)
|
|
65
|
+
File.exist?(File.join(root, "packwerk.yml"))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def todo_files(root)
|
|
69
|
+
Dir.glob(File.join(root, "**", "package_todo.yml")).sort
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Parse one `package_todo.yml` into flat {Violation}s. The referencing package
|
|
73
|
+
# is the file's directory (root-relative). A malformed/empty file is skipped
|
|
74
|
+
# rather than crashing the whole run.
|
|
75
|
+
def violations_in(file, root)
|
|
76
|
+
referencing_package = package_name(File.dirname(file), root)
|
|
77
|
+
data = YAML.safe_load_file(file)
|
|
78
|
+
return [] unless data.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
data.flat_map do |defining_package, constants|
|
|
81
|
+
next [] unless constants.is_a?(Hash)
|
|
82
|
+
constants.flat_map do |constant, detail|
|
|
83
|
+
next [] unless detail.is_a?(Hash)
|
|
84
|
+
types = Array(detail["violations"])
|
|
85
|
+
paths = Array(detail["files"])
|
|
86
|
+
types.product(paths).map do |type, path|
|
|
87
|
+
Violation.new(
|
|
88
|
+
violation_type: type.to_s,
|
|
89
|
+
referencing_package: referencing_package,
|
|
90
|
+
defining_package: defining_package.to_s,
|
|
91
|
+
constant: constant.to_s,
|
|
92
|
+
path: path.to_s
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
rescue Psych::Exception
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Root-relative package name; "." for the root package (packwerk's convention).
|
|
102
|
+
def package_name(dir, root)
|
|
103
|
+
SymbolId.relative_path(dir, root)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# packwerk is not a Moult dependency, so its constant is normally absent; the
|
|
107
|
+
# version is recorded when it happens to be loaded, else nil (nullable in the
|
|
108
|
+
# contract). This is the only reference to the Packwerk constant in Moult.
|
|
109
|
+
def backend_version
|
|
110
|
+
defined?(::Packwerk::VERSION) ? ::Packwerk::VERSION : nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
module Boundaries
|
|
5
|
+
# The per-finding model for architecture boundaries — this slice's realisation
|
|
6
|
+
# of Moult's protected per-finding API. Unlike dead code, a packwerk violation
|
|
7
|
+
# is not a probabilistic guess: packwerk resolved the constant via Zeitwerk and
|
|
8
|
+
# verified it crosses a *declared* boundary, so the honest grade here is a
|
|
9
|
+
# SEVERITY classification, not a confidence. We never manufacture a fake 1.0
|
|
10
|
+
# confidence (which would carry no information); the finding's +confidence+ is
|
|
11
|
+
# null and {classify} assigns a severity by violation kind instead.
|
|
12
|
+
#
|
|
13
|
+
# This keeps the humility invariant in a different register: we still never
|
|
14
|
+
# overstate. A "severity" says how architecturally significant the *kind* of
|
|
15
|
+
# boundary crossing is — it does not assert the code is wrong, only that
|
|
16
|
+
# packwerk recorded a declared-boundary violation of that kind.
|
|
17
|
+
#
|
|
18
|
+
# {classify} is a pure function of the violation type — no IO, no packwerk
|
|
19
|
+
# objects — so it is pinned against hand-built inputs exactly like {ABC}, the
|
|
20
|
+
# coverage {Resolver}, and the duplication {Confidence} model. Drift is a bug.
|
|
21
|
+
module Severity
|
|
22
|
+
CATEGORY = "architecture_boundary"
|
|
23
|
+
|
|
24
|
+
# The ordered severity scale (least → most architecturally significant).
|
|
25
|
+
SCALE = %w[low medium high].freeze
|
|
26
|
+
|
|
27
|
+
# Pinned severity per packwerk violation type. Dependency and layer crossings
|
|
28
|
+
# break the *declared* dependency graph — the core architectural contract —
|
|
29
|
+
# so they rank highest. Privacy/visibility/folder_privacy are reaches past a
|
|
30
|
+
# package's public surface: real violations, but a narrower contract, so
|
|
31
|
+
# medium. An unrecognised type degrades to +low+ (we never drop it).
|
|
32
|
+
SEVERITY = {
|
|
33
|
+
"dependency" => "high",
|
|
34
|
+
"layer" => "high",
|
|
35
|
+
"privacy" => "medium",
|
|
36
|
+
"visibility" => "medium",
|
|
37
|
+
"folder_privacy" => "medium"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
DEFAULT_SEVERITY = "low"
|
|
41
|
+
|
|
42
|
+
# Numeric weight per severity, consumed by the health composite to turn a set
|
|
43
|
+
# of violations into a per-file badness burden. Pinned alongside SEVERITY so
|
|
44
|
+
# the health boundaries component stays deterministic.
|
|
45
|
+
SEVERITY_WEIGHT = {"high" => 1.0, "medium" => 0.6, "low" => 0.3}.freeze
|
|
46
|
+
|
|
47
|
+
# One auditable note behind a classification. Mirrors the shared rule/detail
|
|
48
|
+
# reason shape, but a severity is categorical (not a delta-sum), so it carries
|
|
49
|
+
# no +delta+. Kept local so the boundaries slice does not couple to the
|
|
50
|
+
# dead-code or duplication Reason structs.
|
|
51
|
+
Reason = Struct.new(:rule, :detail) do
|
|
52
|
+
def to_h
|
|
53
|
+
{rule: rule.to_s, detail: detail}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The graded result: a severity on {SCALE} and the reasons behind it.
|
|
58
|
+
Assessment = Struct.new(:severity, :reasons)
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# @param violation_type [String] packwerk violation type, e.g. "dependency"
|
|
63
|
+
# @return [Assessment]
|
|
64
|
+
def classify(violation_type:)
|
|
65
|
+
severity = SEVERITY.fetch(violation_type, DEFAULT_SEVERITY)
|
|
66
|
+
Assessment.new(severity: severity, reasons: [Reason.new(rule: :"#{violation_type}_violation", detail: detail_for(violation_type, severity))])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def detail_for(violation_type, severity)
|
|
70
|
+
case violation_type
|
|
71
|
+
when "dependency"
|
|
72
|
+
"references a constant in a package this one does not declare a dependency on (#{severity})"
|
|
73
|
+
when "layer"
|
|
74
|
+
"depends across a declared architecture layer boundary (#{severity})"
|
|
75
|
+
when "privacy"
|
|
76
|
+
"references another package's private (non-public) constant (#{severity})"
|
|
77
|
+
when "visibility"
|
|
78
|
+
"references a package that does not list this one as visible_to (#{severity})"
|
|
79
|
+
when "folder_privacy"
|
|
80
|
+
"references a nested package outside the allowed folder scope (#{severity})"
|
|
81
|
+
else
|
|
82
|
+
"recorded packwerk boundary violation of an unrecognised type (#{severity})"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
# Orchestrates the architecture-boundaries analysis: it asks the {Boundaries::Packwerk}
|
|
5
|
+
# adapter for every recorded violation, groups them into findings, and grades each
|
|
6
|
+
# group through the pure {Boundaries::Severity} model. The result is a ranked
|
|
7
|
+
# {BoundariesReport} of confidence-null, severity-classified boundary violations —
|
|
8
|
+
# recorded facts, never claims that the code is wrong.
|
|
9
|
+
#
|
|
10
|
+
# This is the only layer that knows where the facts come from; {Severity} stays a
|
|
11
|
+
# pure function of the violation type so it can be pinned in isolation.
|
|
12
|
+
module Boundaries
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A finding is one group of violations sharing this identity (the same constant
|
|
16
|
+
# crossing the same package boundary in the same way); its occurrences are the
|
|
17
|
+
# referencing files.
|
|
18
|
+
GROUP_KEY = %i[referencing_package defining_package constant violation_type].freeze
|
|
19
|
+
|
|
20
|
+
# @param root [String] absolute analysis root
|
|
21
|
+
# @param min_severity [String, nil] drop findings below this severity (low<medium<high)
|
|
22
|
+
# @return [BoundariesReport]
|
|
23
|
+
def build_report(root:, min_severity: nil, git_ref: nil, generated_at: nil)
|
|
24
|
+
result = Packwerk.detect(root: root)
|
|
25
|
+
|
|
26
|
+
findings = group(result.violations).map { |key, violations| finding_for(key, violations) }
|
|
27
|
+
findings.select! { |f| meets?(f.severity, min_severity) } if min_severity
|
|
28
|
+
findings.sort_by! { |f| sort_key(f) }
|
|
29
|
+
|
|
30
|
+
BoundariesReport.new(
|
|
31
|
+
root: root,
|
|
32
|
+
findings: findings,
|
|
33
|
+
git_ref: git_ref,
|
|
34
|
+
generated_at: generated_at,
|
|
35
|
+
backend: result.backend,
|
|
36
|
+
backend_version: result.backend_version,
|
|
37
|
+
configured: result.configured
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def group(violations)
|
|
42
|
+
violations.group_by { |v| GROUP_KEY.map { |k| v[k] } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finding_for(key, violations)
|
|
46
|
+
referencing_package, defining_package, constant, violation_type = key
|
|
47
|
+
assessment = Severity.classify(violation_type: violation_type)
|
|
48
|
+
occurrences = violations
|
|
49
|
+
.map(&:path).uniq.sort
|
|
50
|
+
.map { |path| BoundariesReport::Occurrence.new(symbol_id: nil, path: path) }
|
|
51
|
+
BoundariesReport::Finding.new(
|
|
52
|
+
violation_type: violation_type,
|
|
53
|
+
severity: assessment.severity,
|
|
54
|
+
referencing_package: referencing_package,
|
|
55
|
+
defining_package: defining_package,
|
|
56
|
+
constant: constant,
|
|
57
|
+
reasons: assessment.reasons,
|
|
58
|
+
occurrences: occurrences
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Most-severe first, then a deterministic alphabetical tie-break so output is
|
|
63
|
+
# stable across runs.
|
|
64
|
+
def sort_key(finding)
|
|
65
|
+
[-Severity::SCALE.index(finding.severity), finding.violation_type,
|
|
66
|
+
finding.referencing_package, finding.defining_package, finding.constant]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def meets?(severity, floor)
|
|
70
|
+
Severity::SCALE.index(severity) >= Severity::SCALE.index(floor.to_s)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
require_relative "boundaries/packwerk"
|
|
76
|
+
require_relative "boundaries/severity"
|
|
77
|
+
require_relative "boundaries_report"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
# The serialized result model for `moult boundaries` (schema/boundaries.schema.json),
|
|
5
|
+
# sibling to {DuplicationReport}, {DeadCodeReport}, {CoverageReport} and {HealthReport}.
|
|
6
|
+
# It owns its own JSON envelope and leaves the other protected contracts untouched.
|
|
7
|
+
#
|
|
8
|
+
# Each {Finding} is one recorded architecture-boundary violation group. Unlike the
|
|
9
|
+
# dead-code/duplication contracts it carries +confidence: null+ (a packwerk violation
|
|
10
|
+
# is a recorded fact, not a probabilistic candidate) and a {Boundaries::Severity}
|
|
11
|
+
# classification instead — the honest per-finding grade for this slice. Nothing here
|
|
12
|
+
# asserts the code is *wrong*, only that packwerk recorded a declared-boundary crossing.
|
|
13
|
+
class BoundariesReport
|
|
14
|
+
# Bump only on a breaking change to the serialized shape.
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
# One referencing site of a violation group. +path+ (root-relative) is the join
|
|
18
|
+
# key into the health files[] roll-up. +symbol_id+ is the shared method-level
|
|
19
|
+
# join key, but it is NULL in this slice: packwerk's recorded violations are
|
|
20
|
+
# file-keyed (no line numbers), so there is no line to resolve an enclosing
|
|
21
|
+
# method. It is kept (nullable) for contract consistency with the duplication
|
|
22
|
+
# occurrence shape and to stay forward-compatible with line-level offenses.
|
|
23
|
+
Occurrence = Struct.new(:symbol_id, :path) do
|
|
24
|
+
def to_h
|
|
25
|
+
{symbol_id: symbol_id, path: path}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# One recorded boundary-violation group: a (referencing_package, defining_package,
|
|
30
|
+
# constant, violation_type) tuple referenced from one or more files. Carries its
|
|
31
|
+
# severity and reasons so the classification is auditable.
|
|
32
|
+
Finding = Struct.new(:violation_type, :severity, :referencing_package, :defining_package,
|
|
33
|
+
:constant, :reasons, :occurrences) do
|
|
34
|
+
def to_h
|
|
35
|
+
{
|
|
36
|
+
category: Boundaries::Severity::CATEGORY,
|
|
37
|
+
confidence: nil,
|
|
38
|
+
violation_type: violation_type,
|
|
39
|
+
severity: severity,
|
|
40
|
+
referencing_package: referencing_package,
|
|
41
|
+
defining_package: defining_package,
|
|
42
|
+
constant: constant,
|
|
43
|
+
reasons: reasons.map(&:to_h),
|
|
44
|
+
occurrences: occurrences.map(&:to_h)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_reader :root, :findings, :git_ref, :generated_at, :backend, :backend_version, :configured
|
|
50
|
+
|
|
51
|
+
# @param root [String] absolute analysis root
|
|
52
|
+
# @param findings [Array<Finding>] ranked, most-severe first
|
|
53
|
+
# @param backend [String] detector backend name (e.g. "packwerk")
|
|
54
|
+
# @param backend_version [String, nil] backend gem version, when known
|
|
55
|
+
# @param configured [Boolean] whether the project is packwerk-configured
|
|
56
|
+
def initialize(root:, findings:, git_ref: nil, generated_at: nil,
|
|
57
|
+
backend: "packwerk", backend_version: nil, configured: false)
|
|
58
|
+
@root = root
|
|
59
|
+
@findings = findings
|
|
60
|
+
@git_ref = git_ref
|
|
61
|
+
@generated_at = generated_at
|
|
62
|
+
@backend = backend
|
|
63
|
+
@backend_version = backend_version
|
|
64
|
+
@configured = configured
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Hash] aggregate counts across all violation groups
|
|
68
|
+
def summary
|
|
69
|
+
{
|
|
70
|
+
findings: findings.size,
|
|
71
|
+
violations: findings.sum { |f| f.occurrences.size },
|
|
72
|
+
by_type: tally { |f| f.violation_type },
|
|
73
|
+
by_severity: tally { |f| f.severity }
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
schema_version: SCHEMA_VERSION,
|
|
80
|
+
tool: {name: "moult", version: Moult::VERSION},
|
|
81
|
+
analysis: {
|
|
82
|
+
root: root,
|
|
83
|
+
git_ref: git_ref,
|
|
84
|
+
generated_at: generated_at,
|
|
85
|
+
detector: {
|
|
86
|
+
backend: backend,
|
|
87
|
+
backend_version: backend_version,
|
|
88
|
+
configured: configured
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
summary: summary,
|
|
92
|
+
findings: findings.map(&:to_h)
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Count findings grouped by the yielded key, occurrence-weighted so the totals
|
|
99
|
+
# match the +violations+ count (one violation = one referencing file).
|
|
100
|
+
def tally
|
|
101
|
+
findings.each_with_object(Hash.new(0)) do |finding, acc|
|
|
102
|
+
acc[yield(finding)] += finding.occurrences.size
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/moult/churn.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "git"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# Per-file change frequency from git history. "Change" means a commit that
|
|
7
|
+
# touched the file; the count is the number of such commits within the window.
|
|
8
|
+
#
|
|
9
|
+
# Decisions (v0.1):
|
|
10
|
+
# * Window: the last 12 months by default ({DEFAULT_SINCE}), configurable via
|
|
11
|
+
# +since+ (anything `git log --since` accepts, e.g. "2025-01-01"). All of
|
|
12
|
+
# history over-weights long-lived files, so we bound it.
|
|
13
|
+
# * Renames are NOT followed. `git log --follow` only works for a single
|
|
14
|
+
# pathspec, so whole-repo rename tracking is out of scope; a renamed file
|
|
15
|
+
# starts a fresh count under its new path.
|
|
16
|
+
# * Outside a git repository, churn is empty (every file scores 0).
|
|
17
|
+
#
|
|
18
|
+
# Paths are reported relative to the repository root, as git emits them.
|
|
19
|
+
module Churn
|
|
20
|
+
DEFAULT_SINCE = "12 months ago"
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param root [String] directory to run git in
|
|
25
|
+
# @param since [String] git --since boundary
|
|
26
|
+
# @return [Hash{String=>Integer}] path => commit count (default 0)
|
|
27
|
+
def collect(root:, since: DEFAULT_SINCE)
|
|
28
|
+
output = Git.log_name_only(root, since: since)
|
|
29
|
+
return empty_counts unless output
|
|
30
|
+
|
|
31
|
+
parse(output)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Pure parser over `git log --name-only --pretty=format:` output. Counts how
|
|
35
|
+
# many lines (commits) mention each path.
|
|
36
|
+
# @param output [String]
|
|
37
|
+
# @return [Hash{String=>Integer}]
|
|
38
|
+
def parse(output)
|
|
39
|
+
counts = empty_counts
|
|
40
|
+
output.each_line(chomp: true) do |line|
|
|
41
|
+
next if line.empty?
|
|
42
|
+
|
|
43
|
+
counts[line] += 1
|
|
44
|
+
end
|
|
45
|
+
counts
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def empty_counts
|
|
49
|
+
Hash.new(0)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult boundaries [PATH]` — list recorded architecture-boundary violations from
|
|
9
|
+
# the project's packwerk artifacts, classified by severity. Thin layer: parse
|
|
10
|
+
# options, drive the library, hand the {BoundariesReport} to a formatter.
|
|
11
|
+
# Report-only: exit 0 on success (including when the project is not
|
|
12
|
+
# packwerk-configured), non-zero only on error.
|
|
13
|
+
class BoundariesCommand
|
|
14
|
+
# @return [Integer] process exit status
|
|
15
|
+
def run(argv)
|
|
16
|
+
options = parse(argv)
|
|
17
|
+
return puts_help(options) if options[:help]
|
|
18
|
+
|
|
19
|
+
root = File.expand_path(options[:path])
|
|
20
|
+
unless File.exist?(root)
|
|
21
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
22
|
+
return 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
report = analyze(root, options)
|
|
26
|
+
puts render(report, options)
|
|
27
|
+
0
|
|
28
|
+
rescue OptionParser::ParseError => e
|
|
29
|
+
warn "moult: #{e.message}"
|
|
30
|
+
1
|
|
31
|
+
rescue => e
|
|
32
|
+
warn "moult: #{e.message}"
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse(argv)
|
|
39
|
+
options = {format: :table, min_severity: nil, quiet: false}
|
|
40
|
+
@parser = OptionParser.new do |o|
|
|
41
|
+
o.banner = "Usage: moult boundaries [PATH] [options]"
|
|
42
|
+
o.separator ""
|
|
43
|
+
o.separator "Options:"
|
|
44
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
45
|
+
o.on("--min-severity SEV", Boundaries::Severity::SCALE, "Hide findings below this severity: low, medium, high") { |v| options[:min_severity] = v }
|
|
46
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
47
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
48
|
+
end
|
|
49
|
+
@parser.permute!(argv)
|
|
50
|
+
options[:path] = argv.shift || "."
|
|
51
|
+
options
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def puts_help(_options)
|
|
55
|
+
puts @parser
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def analyze(root, options)
|
|
60
|
+
root_dir = File.directory?(root) ? root : File.dirname(root)
|
|
61
|
+
report = Boundaries.build_report(
|
|
62
|
+
root: root_dir,
|
|
63
|
+
min_severity: options[:min_severity],
|
|
64
|
+
git_ref: Git.head_ref(root_dir),
|
|
65
|
+
generated_at: Time.now.utc.iso8601
|
|
66
|
+
)
|
|
67
|
+
note(options, report.configured ? "read packwerk artifacts: #{report.summary[:findings]} violation groups." : "no packwerk.yml found; not a packwerk project.")
|
|
68
|
+
report
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def render(report, options)
|
|
72
|
+
case options[:format]
|
|
73
|
+
when :json then Formatters::BoundariesJson.render(report)
|
|
74
|
+
else Formatters::BoundariesTable.render(report)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def note(options, message)
|
|
79
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|