moult 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +4 -0
- data/README.md +331 -0
- data/exe/moult +6 -0
- data/lib/moult/abc.rb +133 -0
- data/lib/moult/boundaries/packwerk.rb +114 -0
- data/lib/moult/boundaries/severity.rb +87 -0
- data/lib/moult/boundaries.rb +77 -0
- data/lib/moult/boundaries_report.rb +106 -0
- data/lib/moult/churn.rb +52 -0
- data/lib/moult/cli/boundaries_command.rb +83 -0
- data/lib/moult/cli/coverage_command.rb +101 -0
- data/lib/moult/cli/dead_code_command.rb +112 -0
- data/lib/moult/cli/duplication_command.rb +92 -0
- data/lib/moult/cli/flags_command.rb +95 -0
- data/lib/moult/cli/gate_command.rb +113 -0
- data/lib/moult/cli/health_command.rb +117 -0
- data/lib/moult/cli/hotspots_command.rb +104 -0
- data/lib/moult/cli.rb +102 -0
- data/lib/moult/clones.rb +91 -0
- data/lib/moult/cloud_upload.rb +29 -0
- data/lib/moult/confidence/rules.rb +128 -0
- data/lib/moult/confidence.rb +106 -0
- data/lib/moult/coverage/resolver.rb +56 -0
- data/lib/moult/coverage.rb +176 -0
- data/lib/moult/coverage_report.rb +98 -0
- data/lib/moult/dead_code.rb +119 -0
- data/lib/moult/dead_code_report.rb +65 -0
- data/lib/moult/diff.rb +177 -0
- data/lib/moult/discovery.rb +38 -0
- data/lib/moult/duplication/confidence.rb +92 -0
- data/lib/moult/duplication.rb +112 -0
- data/lib/moult/duplication_report.rb +89 -0
- data/lib/moult/flag_scanner.rb +150 -0
- data/lib/moult/flags/classification.rb +79 -0
- data/lib/moult/flags/snapshot.rb +162 -0
- data/lib/moult/flags/staleness.rb +145 -0
- data/lib/moult/flags.rb +131 -0
- data/lib/moult/flags_report.rb +136 -0
- data/lib/moult/formatters/boundaries_json.rb +20 -0
- data/lib/moult/formatters/boundaries_table.rb +53 -0
- data/lib/moult/formatters/coverage_json.rb +19 -0
- data/lib/moult/formatters/coverage_table.rb +60 -0
- data/lib/moult/formatters/dead_code_json.rb +20 -0
- data/lib/moult/formatters/dead_code_table.rb +66 -0
- data/lib/moult/formatters/duplication_json.rb +20 -0
- data/lib/moult/formatters/duplication_table.rb +55 -0
- data/lib/moult/formatters/flags_json.rb +20 -0
- data/lib/moult/formatters/flags_table.rb +76 -0
- data/lib/moult/formatters/gate_github.rb +52 -0
- data/lib/moult/formatters/gate_json.rb +20 -0
- data/lib/moult/formatters/gate_message.rb +19 -0
- data/lib/moult/formatters/gate_sarif.rb +78 -0
- data/lib/moult/formatters/gate_table.rb +71 -0
- data/lib/moult/formatters/health_json.rb +20 -0
- data/lib/moult/formatters/health_table.rb +80 -0
- data/lib/moult/formatters/json.rb +23 -0
- data/lib/moult/formatters/table.rb +70 -0
- data/lib/moult/formatters/text_table.rb +39 -0
- data/lib/moult/gate/config.rb +55 -0
- data/lib/moult/gate/evaluation.rb +172 -0
- data/lib/moult/gate/policy.rb +103 -0
- data/lib/moult/gate.rb +199 -0
- data/lib/moult/gate_report.rb +97 -0
- data/lib/moult/git.rb +83 -0
- data/lib/moult/health/score.rb +291 -0
- data/lib/moult/health.rb +320 -0
- data/lib/moult/health_report.rb +97 -0
- data/lib/moult/index.rb +228 -0
- data/lib/moult/parser.rb +101 -0
- data/lib/moult/rails_conventions.rb +124 -0
- data/lib/moult/report.rb +114 -0
- data/lib/moult/scoring.rb +82 -0
- data/lib/moult/span.rb +17 -0
- data/lib/moult/symbol_id.rb +30 -0
- data/lib/moult/symbol_scanner.rb +100 -0
- data/lib/moult/version.rb +5 -0
- data/lib/moult.rb +84 -0
- data/schema/boundaries.schema.json +125 -0
- data/schema/common.schema.json +76 -0
- data/schema/coverage.schema.json +83 -0
- data/schema/deadcode.schema.json +106 -0
- data/schema/duplication.schema.json +128 -0
- data/schema/flags.schema.json +157 -0
- data/schema/gate.schema.json +165 -0
- data/schema/health.schema.json +157 -0
- data/schema/hotspots.schema.json +106 -0
- metadata +185 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
module Gate
|
|
5
|
+
# The pure verdict engine: given already-scoped observations and a {Policy},
|
|
6
|
+
# it decides each rule's outcome and the single top-level verdict. No IO, no
|
|
7
|
+
# git, no analysis objects — just the policy applied to hand-buildable facts —
|
|
8
|
+
# so it is pinned in test/test_gate_policy.rb. Drift is a bug.
|
|
9
|
+
#
|
|
10
|
+
# The verdict is an auditable APPLICATION of a recorded policy over
|
|
11
|
+
# confidence-graded candidates; it never claims code is certainly wrong or
|
|
12
|
+
# dead. A rule whose backing analysis didn't run is marked `evaluated: false`
|
|
13
|
+
# and never fails the gate (a broken tool is a tool-error concern, surfaced by
|
|
14
|
+
# the CLI exit code, not a policy violation).
|
|
15
|
+
module Evaluation
|
|
16
|
+
SEVERITY_SCALE = Boundaries::Severity::SCALE
|
|
17
|
+
|
|
18
|
+
# ---- observation inputs (one row per scoped finding) ----------------------
|
|
19
|
+
DeadCodeObs = Struct.new(:symbol_id, :path, :line, :confidence)
|
|
20
|
+
BoundaryObs = Struct.new(:symbol_id, :path, :line, :severity, :violation_type)
|
|
21
|
+
ComplexityObs = Struct.new(:symbol_id, :path, :line, :abc)
|
|
22
|
+
DuplicationObs = Struct.new(:symbol_id, :path, :line, :mass)
|
|
23
|
+
|
|
24
|
+
# The full input. Each analysis's list is nil when that analysis was skipped
|
|
25
|
+
# (errored, or not applicable — e.g. a non-packwerk repo for boundaries);
|
|
26
|
+
# +diagnostics+ maps a skipped analysis to its reason.
|
|
27
|
+
Observations = Struct.new(:dead_code, :boundaries, :complexity, :duplication, :diagnostics) do
|
|
28
|
+
def initialize(dead_code: nil, boundaries: nil, complexity: nil, duplication: nil, diagnostics: {})
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ---- output ---------------------------------------------------------------
|
|
34
|
+
Reason = Struct.new(:rule, :detail) do
|
|
35
|
+
def to_h
|
|
36
|
+
{rule: rule.to_s, detail: detail}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One contributing finding behind a rule outcome. Stays confidence-graded:
|
|
41
|
+
# +value+ is the observed signal (confidence/abc/mass/severity), never a
|
|
42
|
+
# claim of certainty.
|
|
43
|
+
Contribution = Struct.new(:category, :path, :symbol_id, :line, :value) do
|
|
44
|
+
def to_h
|
|
45
|
+
{category: category, path: path, symbol_id: symbol_id, line: line, value: value}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
RuleOutcome = Struct.new(:rule, :evaluated, :observed, :threshold, :passed, :reasons, :findings) do
|
|
50
|
+
def to_h
|
|
51
|
+
{
|
|
52
|
+
rule: rule,
|
|
53
|
+
evaluated: evaluated,
|
|
54
|
+
observed: observed,
|
|
55
|
+
threshold: threshold,
|
|
56
|
+
passed: passed,
|
|
57
|
+
reasons: reasons.map(&:to_h),
|
|
58
|
+
findings: findings.map(&:to_h)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Verdict = Struct.new(:verdict, :reasons, :rules)
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
# @param observations [Observations]
|
|
68
|
+
# @param policy [Policy]
|
|
69
|
+
# @return [Verdict]
|
|
70
|
+
def evaluate(observations:, policy:)
|
|
71
|
+
diags = observations.diagnostics || {}
|
|
72
|
+
rules = [
|
|
73
|
+
dead_code_rule(observations.dead_code, policy, diags[:dead_code]),
|
|
74
|
+
boundary_rule(observations.boundaries, policy, diags[:boundaries]),
|
|
75
|
+
complexity_rule(observations.complexity, policy, diags[:complexity]),
|
|
76
|
+
duplication_rule(observations.duplication, policy, diags[:duplication])
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
failed = rules.select { |r| r.evaluated && r.passed == false }
|
|
80
|
+
verdict = failed.empty? ? "pass" : "fail"
|
|
81
|
+
Verdict.new(verdict: verdict, reasons: verdict_reasons(verdict, failed), rules: rules)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ---- rules ----------------------------------------------------------------
|
|
85
|
+
#
|
|
86
|
+
# Each rule names the threshold and the genuinely varying bits — which
|
|
87
|
+
# observations violate it, the worst observed value, the per-finding value,
|
|
88
|
+
# and a noun phrase for the detail — and hands them to {outcome}, which owns
|
|
89
|
+
# the shared RuleOutcome construction.
|
|
90
|
+
|
|
91
|
+
def dead_code_rule(obs, policy, diagnostic)
|
|
92
|
+
t = policy.dead_code_max_confidence
|
|
93
|
+
outcome("no_new_dead_code", "< #{t}", obs, diagnostic, "dead_code") do |list|
|
|
94
|
+
violating = list.select { |o| o.confidence >= t }
|
|
95
|
+
detail = phrase(violating, "no new dead-code candidate reaches confidence #{t} on changed lines",
|
|
96
|
+
"new dead-code candidate(s) at or above confidence #{t} on changed lines")
|
|
97
|
+
[violating, list.map(&:confidence).max, detail, ->(o) { o.confidence }]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def boundary_rule(obs, policy, diagnostic)
|
|
102
|
+
t = policy.boundary_max_severity
|
|
103
|
+
ti = SEVERITY_SCALE.index(t) || 0
|
|
104
|
+
outcome("no_new_high_severity_boundary", "<= #{t}", obs, diagnostic, "architecture_boundary") do |list|
|
|
105
|
+
violating = list.select { |o| (SEVERITY_SCALE.index(o.severity) || 0) > ti }
|
|
106
|
+
detail = phrase(violating, "no new boundary violation exceeds #{t} severity in changed files",
|
|
107
|
+
"new boundary violation(s) above #{t} severity in changed files")
|
|
108
|
+
[violating, list.map(&:severity).max_by { |s| SEVERITY_SCALE.index(s) || -1 }, detail, ->(o) { o.severity }]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def complexity_rule(obs, policy, diagnostic)
|
|
113
|
+
t = policy.complexity_ceiling
|
|
114
|
+
outcome("new_code_complexity_ceiling", "<= #{t}", obs, diagnostic, "complexity") do |list|
|
|
115
|
+
violating = list.select { |o| o.abc > t }
|
|
116
|
+
detail = phrase(violating, "no changed method exceeds ABC complexity #{t}",
|
|
117
|
+
"changed method(s) exceed ABC complexity #{t}")
|
|
118
|
+
[violating, list.map(&:abc).max, detail, ->(o) { o.abc }]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def duplication_rule(obs, policy, diagnostic)
|
|
123
|
+
t = policy.duplication_max_mass
|
|
124
|
+
outcome("new_code_duplication_ceiling", "<= #{t}", obs, diagnostic, "structural_duplication") do |list|
|
|
125
|
+
violating = list.select { |o| o.mass > t }
|
|
126
|
+
detail = phrase(violating, "no clone group touching the diff exceeds mass #{t}",
|
|
127
|
+
"clone group(s) touching the diff exceed mass #{t}")
|
|
128
|
+
[violating, list.map(&:mass).max, detail, ->(o) { o.mass }]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- shared construction --------------------------------------------------
|
|
133
|
+
|
|
134
|
+
# Build a rule's outcome. The block, given the (non-nil) observation list,
|
|
135
|
+
# returns [violating, observed, detail, value_extractor]; a nil list means the
|
|
136
|
+
# backing analysis was skipped, so the rule is not evaluated and cannot fail.
|
|
137
|
+
def outcome(rule, threshold, obs, diagnostic, category)
|
|
138
|
+
return skipped(rule, threshold, diagnostic) if obs.nil?
|
|
139
|
+
|
|
140
|
+
violating, observed, detail, value = yield(obs)
|
|
141
|
+
RuleOutcome.new(
|
|
142
|
+
rule: rule, evaluated: true, observed: observed, threshold: threshold,
|
|
143
|
+
passed: violating.empty?,
|
|
144
|
+
reasons: [Reason.new(rule: rule.to_sym, detail: detail)],
|
|
145
|
+
findings: violating.map { |o| Contribution.new(category: category, path: o.path, symbol_id: o.symbol_id, line: o.line, value: value.call(o)) }
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def skipped(rule, threshold, diagnostic)
|
|
150
|
+
RuleOutcome.new(
|
|
151
|
+
rule: rule, evaluated: false,
|
|
152
|
+
observed: nil, threshold: threshold, passed: nil,
|
|
153
|
+
reasons: [Reason.new(rule: :skipped, detail: diagnostic || "backing analysis did not run; rule not evaluated")],
|
|
154
|
+
findings: []
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# "no X ..." when nothing violates, "<n> X ..." otherwise.
|
|
159
|
+
def phrase(violating, none, some)
|
|
160
|
+
violating.empty? ? none : "#{violating.size} #{some}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def verdict_reasons(verdict, failed)
|
|
164
|
+
if verdict == "pass"
|
|
165
|
+
[Reason.new(rule: :clean_as_you_code, detail: "all evaluated policy rules passed on the scoped changes")]
|
|
166
|
+
else
|
|
167
|
+
failed.map { |r| Reason.new(rule: r.rule.to_sym, detail: r.reasons.first.detail) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
module Gate
|
|
5
|
+
# The explicit, recorded set of thresholds the gate enforces — the realisation
|
|
6
|
+
# of "Clean as You Code" for Moult. A Policy is a plain value object; the
|
|
7
|
+
# thresholds it carries are serialized into every gate report so the verdict is
|
|
8
|
+
# auditable (never a hidden heuristic) and reproducible.
|
|
9
|
+
#
|
|
10
|
+
# The {DEFAULTS} are judgement-based heuristics in the spirit of the health
|
|
11
|
+
# knees — calibrated against a real corpus (Moult's own codebase: a default
|
|
12
|
+
# must let a clean, well-tested gem pass). They are pinned in
|
|
13
|
+
# test/test_gate_policy.rb; drift is a bug. Teams override them via the `gate:`
|
|
14
|
+
# section of .moult.yml.
|
|
15
|
+
class Policy
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
# A dead-code candidate on changed lines at or above this confidence fails
|
|
18
|
+
# the gate. Public symbols base well below this; the rule bites freshly
|
|
19
|
+
# added unused private methods — the canonical "new dead code" smell.
|
|
20
|
+
dead_code_max_confidence: 0.8,
|
|
21
|
+
|
|
22
|
+
# The highest boundary severity allowed to appear in a changed file. With
|
|
23
|
+
# "medium", a new HIGH-severity packwerk violation (dependency/layer) fails.
|
|
24
|
+
boundary_max_severity: "medium",
|
|
25
|
+
|
|
26
|
+
# A changed method whose ABC complexity exceeds this ceiling fails the gate.
|
|
27
|
+
complexity_ceiling: 30.0,
|
|
28
|
+
|
|
29
|
+
# A clone group touching the diff whose flay mass exceeds this fails. Set to
|
|
30
|
+
# roughly a fully duplicated ~10-line method: below it lies idiomatic
|
|
31
|
+
# parallelism (sibling guard clauses, similar small methods) that a clean
|
|
32
|
+
# codebase legitimately has, so a lower bar produces noise, not signal.
|
|
33
|
+
duplication_max_mass: 100,
|
|
34
|
+
|
|
35
|
+
# Path prefixes excluded from gating. Test/spec code is legitimately
|
|
36
|
+
# repetitive (parallel cases, shared setup), so — like SonarQube and
|
|
37
|
+
# CodeScene — the gate judges production code. Findings under these prefixes
|
|
38
|
+
# are dropped from every rule. Override to [] to gate everything.
|
|
39
|
+
exclude_paths: ["test", "spec"]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
KEYS = DEFAULTS.keys.freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :dead_code_max_confidence, :boundary_max_severity,
|
|
45
|
+
:complexity_ceiling, :duplication_max_mass, :exclude_paths, :source
|
|
46
|
+
|
|
47
|
+
def initialize(dead_code_max_confidence:, boundary_max_severity:,
|
|
48
|
+
complexity_ceiling:, duplication_max_mass:, exclude_paths:, source:)
|
|
49
|
+
@dead_code_max_confidence = dead_code_max_confidence
|
|
50
|
+
@boundary_max_severity = boundary_max_severity
|
|
51
|
+
@complexity_ceiling = complexity_ceiling
|
|
52
|
+
@duplication_max_mass = duplication_max_mass
|
|
53
|
+
@exclude_paths = exclude_paths
|
|
54
|
+
@source = source
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# The pinned defaults, recorded with source "default".
|
|
59
|
+
# @return [Policy]
|
|
60
|
+
def default
|
|
61
|
+
load({}, source: "default")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Merge a (string- or symbol-keyed) overrides hash onto {DEFAULTS}. Unknown
|
|
65
|
+
# keys are ignored so a stray .moult.yml entry can't silently weaken the gate.
|
|
66
|
+
# @param overrides [Hash]
|
|
67
|
+
# @param source [String] provenance for the report (e.g. ".moult.yml")
|
|
68
|
+
# @return [Policy]
|
|
69
|
+
def load(overrides, source:)
|
|
70
|
+
new(**DEFAULTS.merge(sanitize(overrides)), source: source)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def sanitize(overrides)
|
|
76
|
+
(overrides || {}).each_with_object({}) do |(key, value), acc|
|
|
77
|
+
sym = key.to_sym
|
|
78
|
+
acc[sym] = value if KEYS.include?(sym)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Is +path+ (root-relative) outside the gate's scope — i.e. under an excluded
|
|
84
|
+
# prefix like test/ or spec/?
|
|
85
|
+
def excluded?(path)
|
|
86
|
+
segment = path.to_s.split("/").first
|
|
87
|
+
exclude_paths.include?(segment)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The auditable record of every applied threshold.
|
|
91
|
+
def to_h
|
|
92
|
+
{
|
|
93
|
+
source: source,
|
|
94
|
+
dead_code_max_confidence: dead_code_max_confidence,
|
|
95
|
+
boundary_max_severity: boundary_max_severity,
|
|
96
|
+
complexity_ceiling: complexity_ceiling,
|
|
97
|
+
duplication_max_mass: duplication_max_mass,
|
|
98
|
+
exclude_paths: exclude_paths
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/moult/gate.rb
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
# Orchestrates the diff-aware PR risk gate — the capstone of the static layer and
|
|
5
|
+
# the gem-level core of what later becomes the GitHub App.
|
|
6
|
+
#
|
|
7
|
+
# It reuses {Health}'s composer discipline: each signal analysis runs inside its
|
|
8
|
+
# own rescue so one failure degrades that rule (evaluated: false) rather than
|
|
9
|
+
# crashing the gate. It then scopes every finding to the diff (via {Diff}),
|
|
10
|
+
# extracts pure observations, and hands them to the pinned {Gate::Evaluation}
|
|
11
|
+
# model together with the recorded {Gate::Policy}. This file is the only layer
|
|
12
|
+
# that does IO and knows where the signals come from; Policy/Evaluation stay pure
|
|
13
|
+
# functions so they can be pinned in isolation.
|
|
14
|
+
#
|
|
15
|
+
# The gate consumes signals and renders a verdict; it never mutates a signal
|
|
16
|
+
# contract, and the two protected APIs are untouched. The verdict is an auditable
|
|
17
|
+
# application of an explicit policy over confidence-graded candidates — never a
|
|
18
|
+
# claim that code is certainly wrong or dead.
|
|
19
|
+
module Gate
|
|
20
|
+
# Fixed component order so the provenance block is stable.
|
|
21
|
+
KNOWN_COMPONENTS = %w[complexity dead_code duplication boundaries].freeze
|
|
22
|
+
|
|
23
|
+
# The outcome of one isolated analysis run (mirrors {Health::Run}).
|
|
24
|
+
Run = Struct.new(:value, :error) do
|
|
25
|
+
def ok?
|
|
26
|
+
error.nil? && !value.nil?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param root [String] absolute analysis root (should be the repo root)
|
|
33
|
+
# @param files [Array<String>] absolute Ruby file paths to analyse
|
|
34
|
+
# @param index [Index] resolved definition/reference index (drives dead code)
|
|
35
|
+
# @param rails [RailsConventions] Rails entrypoint awareness for dead code
|
|
36
|
+
# @param base_ref [String] base ref for the diff (e.g. "origin/main")
|
|
37
|
+
# @param scope [Symbol] :diff (default) or :all
|
|
38
|
+
# @param policy [Gate::Policy] the thresholds to apply
|
|
39
|
+
# @param churn_since [String, nil] churn window for the complexity analysis
|
|
40
|
+
# @return [GateReport]
|
|
41
|
+
def build_report(root:, files:, index:, rails:, base_ref:, scope:, policy:,
|
|
42
|
+
git_ref: nil, generated_at: nil, churn_since: nil)
|
|
43
|
+
diff = Diff.compute(root: root, base_ref: base_ref, scope: scope)
|
|
44
|
+
runs = run_analyses(root: root, files: files, index: index, rails: rails, churn_since: churn_since)
|
|
45
|
+
observations = observe(runs, diff, policy)
|
|
46
|
+
|
|
47
|
+
GateReport.new(
|
|
48
|
+
root: root,
|
|
49
|
+
base_ref: diff.base_ref,
|
|
50
|
+
merge_base: diff.merge_base,
|
|
51
|
+
scope: diff.scope,
|
|
52
|
+
components: component_views(runs),
|
|
53
|
+
policy: policy,
|
|
54
|
+
evaluation: Evaluation.evaluate(observations: observations, policy: policy),
|
|
55
|
+
git_ref: git_ref,
|
|
56
|
+
generated_at: generated_at
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Run each signal analysis in isolation (the composer discipline).
|
|
61
|
+
def run_analyses(root:, files:, index:, rails:, churn_since:)
|
|
62
|
+
# Churn is only collected to satisfy {Scoring}; the complexity rule reads
|
|
63
|
+
# each method's ABC + span, which are churn-independent.
|
|
64
|
+
churn = Churn.collect(root: root, since: churn_since || Churn::DEFAULT_SINCE)
|
|
65
|
+
{
|
|
66
|
+
"complexity" => run { Scoring.build_report(root: root, files: files, churn: churn) },
|
|
67
|
+
"dead_code" => run { DeadCode.build_report(root: root, files: files, index: index, rails: rails) },
|
|
68
|
+
"duplication" => run { Duplication.build_report(root: root, files: files) },
|
|
69
|
+
"boundaries" => run { Boundaries.build_report(root: root) }
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Scope every analysis's findings to the diff, into pure observations, then drop
|
|
74
|
+
# any under an excluded path (test/spec) so the gate judges production code.
|
|
75
|
+
def observe(runs, diff, policy)
|
|
76
|
+
Evaluation::Observations.new(
|
|
77
|
+
complexity: gated(scope_complexity(runs["complexity"], diff), policy),
|
|
78
|
+
dead_code: gated(scope_dead_code(runs["dead_code"], diff), policy),
|
|
79
|
+
duplication: gated(scope_duplication(runs["duplication"], diff), policy),
|
|
80
|
+
boundaries: gated(scope_boundaries(runs["boundaries"], diff), policy),
|
|
81
|
+
diagnostics: diagnostics(runs)
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Drop excluded-path observations; nil (a skipped analysis) passes through.
|
|
86
|
+
def gated(observations, policy)
|
|
87
|
+
return nil if observations.nil?
|
|
88
|
+
|
|
89
|
+
observations.reject { |o| policy.excluded?(o.path) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Run one analysis in isolation (mirrors {Health.run}).
|
|
93
|
+
def run
|
|
94
|
+
Run.new(value: yield, error: nil)
|
|
95
|
+
rescue => e
|
|
96
|
+
Run.new(value: nil, error: e.message)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ---- scoping (analysis findings -> pure, diff-filtered observations) -------
|
|
100
|
+
|
|
101
|
+
# nil signals a skipped analysis (errored): its rule is not evaluated.
|
|
102
|
+
def scope_complexity(run, diff)
|
|
103
|
+
return nil unless run.ok?
|
|
104
|
+
|
|
105
|
+
run.value.hotspots.flat_map do |hotspot|
|
|
106
|
+
hotspot.methods.filter_map do |method|
|
|
107
|
+
next unless diff.in_diff?(path: hotspot.path, start_line: method.span.start_line, end_line: method.span.end_line)
|
|
108
|
+
|
|
109
|
+
Evaluation::ComplexityObs.new(
|
|
110
|
+
symbol_id: method.symbol_id, path: hotspot.path,
|
|
111
|
+
line: method.span.start_line, abc: method.abc
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def scope_dead_code(run, diff)
|
|
118
|
+
return nil unless run.ok?
|
|
119
|
+
|
|
120
|
+
run.value.findings.filter_map do |finding|
|
|
121
|
+
next unless diff.in_diff?(path: finding.path, start_line: finding.span.start_line, end_line: finding.span.end_line)
|
|
122
|
+
|
|
123
|
+
Evaluation::DeadCodeObs.new(
|
|
124
|
+
symbol_id: finding.symbol_id, path: finding.path,
|
|
125
|
+
line: finding.span.start_line, confidence: finding.confidence
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# One observation per clone GROUP touching the diff (mass is a group property);
|
|
131
|
+
# attributed to its first in-diff occurrence.
|
|
132
|
+
def scope_duplication(run, diff)
|
|
133
|
+
return nil unless run.ok?
|
|
134
|
+
|
|
135
|
+
run.value.findings.filter_map do |finding|
|
|
136
|
+
occ = finding.occurrences.find { |o| diff.in_diff?(path: o.path, start_line: o.line, end_line: o.line) }
|
|
137
|
+
next unless occ
|
|
138
|
+
|
|
139
|
+
Evaluation::DuplicationObs.new(
|
|
140
|
+
symbol_id: occ.symbol_id, path: occ.path, line: occ.line, mass: finding.mass
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Boundaries are file-keyed (null symbol_id), so they scope at PATH granularity.
|
|
146
|
+
# Skipped unless the project is actually packwerk-configured.
|
|
147
|
+
def scope_boundaries(run, diff)
|
|
148
|
+
return nil unless boundaries_contributes?(run)
|
|
149
|
+
|
|
150
|
+
run.value.findings.flat_map do |finding|
|
|
151
|
+
finding.occurrences.filter_map do |occ|
|
|
152
|
+
next unless diff.includes_path?(occ.path)
|
|
153
|
+
|
|
154
|
+
Evaluation::BoundaryObs.new(
|
|
155
|
+
symbol_id: nil, path: occ.path, line: nil,
|
|
156
|
+
severity: finding.severity, violation_type: finding.violation_type
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def boundaries_contributes?(run)
|
|
163
|
+
run.ok? && run.value.configured
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ---- provenance -----------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def diagnostics(runs)
|
|
169
|
+
diags = {}
|
|
170
|
+
%w[complexity dead_code duplication].each do |name|
|
|
171
|
+
diags[name.to_sym] = runs[name].error if runs[name].error
|
|
172
|
+
end
|
|
173
|
+
unless boundaries_contributes?(runs["boundaries"])
|
|
174
|
+
diags[:boundaries] = runs["boundaries"].error || "not a packwerk project (no packwerk.yml)"
|
|
175
|
+
end
|
|
176
|
+
diags
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def component_views(runs)
|
|
180
|
+
KNOWN_COMPONENTS.map do |name|
|
|
181
|
+
present = (name == "boundaries") ? boundaries_contributes?(runs[name]) : runs[name].ok?
|
|
182
|
+
diagnostic = present ? nil : component_diagnostic(name, runs[name])
|
|
183
|
+
GateReport::Component.new(name: name, present: present, diagnostic: diagnostic)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def component_diagnostic(name, run)
|
|
188
|
+
return run.error if run.error
|
|
189
|
+
return "not a packwerk project (no packwerk.yml)" if name == "boundaries"
|
|
190
|
+
|
|
191
|
+
"analysis produced no result"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
require_relative "gate/policy"
|
|
197
|
+
require_relative "gate/evaluation"
|
|
198
|
+
require_relative "gate/config"
|
|
199
|
+
require_relative "gate_report"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
# The serialized result model for `moult gate` (schema/gate.schema.json), sibling
|
|
5
|
+
# to {HealthReport} and {BoundariesReport}. It owns its own JSON envelope and
|
|
6
|
+
# leaves every signal contract — and the two protected APIs — untouched.
|
|
7
|
+
#
|
|
8
|
+
# This is the FIRST and ONLY Moult contract that renders a VERDICT. Every other
|
|
9
|
+
# analysis emits a confidence-graded / classified SIGNAL with no pass/fail; the
|
|
10
|
+
# gate consumes those signals, applies an explicit recorded {Gate::Policy}, and
|
|
11
|
+
# reports one top-level verdict. The verdict is an auditable application of that
|
|
12
|
+
# policy over confidence-graded candidates — never a claim that code is certainly
|
|
13
|
+
# wrong or dead. The words "verdict"/"pass"/"fail" live here and nowhere else.
|
|
14
|
+
class GateReport
|
|
15
|
+
# Bump only on a breaking change to the serialized shape.
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
# Provenance for one signal analysis the gate composed: did it contribute, and
|
|
19
|
+
# if not, why (errored, or not applicable — e.g. a non-packwerk repo).
|
|
20
|
+
Component = Struct.new(:name, :present, :diagnostic) do
|
|
21
|
+
def to_h
|
|
22
|
+
{name: name, present: present, diagnostic: diagnostic}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :root, :git_ref, :generated_at, :base_ref, :merge_base, :scope,
|
|
27
|
+
:components, :policy, :evaluation
|
|
28
|
+
|
|
29
|
+
# @param root [String] absolute analysis root
|
|
30
|
+
# @param base_ref [String, nil] requested base ref (nil under :all scope)
|
|
31
|
+
# @param merge_base [String, nil] resolved merge-base sha (nil under :all scope)
|
|
32
|
+
# @param scope [Symbol] :diff or :all
|
|
33
|
+
# @param components [Array<Component>] which signal analyses ran/were skipped
|
|
34
|
+
# @param policy [Gate::Policy] the applied policy (recorded in full)
|
|
35
|
+
# @param evaluation [Gate::Evaluation::Verdict] verdict + per-rule outcomes
|
|
36
|
+
def initialize(root:, base_ref:, merge_base:, scope:, components:, policy:, evaluation:,
|
|
37
|
+
git_ref: nil, generated_at: nil)
|
|
38
|
+
@root = root
|
|
39
|
+
@base_ref = base_ref
|
|
40
|
+
@merge_base = merge_base
|
|
41
|
+
@scope = scope
|
|
42
|
+
@components = components
|
|
43
|
+
@policy = policy
|
|
44
|
+
@evaluation = evaluation
|
|
45
|
+
@git_ref = git_ref
|
|
46
|
+
@generated_at = generated_at
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The top-level verdict, "pass" or "fail". The CLI maps this to its exit code.
|
|
50
|
+
def verdict
|
|
51
|
+
evaluation.verdict
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rules
|
|
55
|
+
evaluation.rules
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reasons
|
|
59
|
+
evaluation.reasons
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Contributing findings flattened across all rules (for CI projections).
|
|
63
|
+
def findings
|
|
64
|
+
rules.flat_map(&:findings)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def summary
|
|
68
|
+
{
|
|
69
|
+
rules: rules.size,
|
|
70
|
+
evaluated: rules.count(&:evaluated),
|
|
71
|
+
failed: rules.count { |r| r.evaluated && r.passed == false },
|
|
72
|
+
findings: findings.size
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
{
|
|
78
|
+
schema_version: SCHEMA_VERSION,
|
|
79
|
+
tool: {name: "moult", version: Moult::VERSION},
|
|
80
|
+
analysis: {
|
|
81
|
+
root: root,
|
|
82
|
+
git_ref: git_ref,
|
|
83
|
+
generated_at: generated_at,
|
|
84
|
+
base_ref: base_ref,
|
|
85
|
+
merge_base: merge_base,
|
|
86
|
+
scope: scope.to_s,
|
|
87
|
+
components: components.map(&:to_h)
|
|
88
|
+
},
|
|
89
|
+
policy: policy.to_h,
|
|
90
|
+
verdict: verdict,
|
|
91
|
+
reasons: reasons.map(&:to_h),
|
|
92
|
+
summary: summary,
|
|
93
|
+
rules: rules.map(&:to_h)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/moult/git.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# Thin, injection-safe wrapper over the git CLI. All commands run with an
|
|
7
|
+
# explicit working directory and argument array (never a shell string), and
|
|
8
|
+
# failures surface as nil/false rather than exceptions so callers can degrade
|
|
9
|
+
# gracefully outside a repository.
|
|
10
|
+
module Git
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @return [Boolean] whether +dir+ is inside a git work tree
|
|
14
|
+
def repo?(dir)
|
|
15
|
+
out, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: dir)
|
|
16
|
+
status.success? && out.strip == "true"
|
|
17
|
+
rescue SystemCallError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] the HEAD commit sha, or nil outside a repo
|
|
22
|
+
def head_ref(dir)
|
|
23
|
+
out = capture(dir, "rev-parse", "HEAD")
|
|
24
|
+
out&.strip
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<String>] tracked + untracked-but-not-ignored files,
|
|
28
|
+
# relative to +dir+, respecting .gitignore. Empty outside a repo.
|
|
29
|
+
def listed_files(dir)
|
|
30
|
+
out = capture(dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z")
|
|
31
|
+
return [] unless out
|
|
32
|
+
|
|
33
|
+
out.split("\x0").reject(&:empty?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raw `git log` file listing for churn: one path per line, blank lines
|
|
37
|
+
# between commits. Each commit lists a touched path at most once.
|
|
38
|
+
# @return [String, nil] nil outside a repo
|
|
39
|
+
def log_name_only(dir, since:)
|
|
40
|
+
capture(dir, "log", "--since=#{since}", "--name-only", "--pretty=format:")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ---- diff adapter (drives the PR risk gate's diff scoping) ----------------
|
|
44
|
+
#
|
|
45
|
+
# These three are the ONLY git calls behind the diff-aware gate. They return
|
|
46
|
+
# raw text (or nil/false on failure); {Diff} owns all parsing, so this file
|
|
47
|
+
# stays the single shell gateway and the parser can be pinned in isolation.
|
|
48
|
+
|
|
49
|
+
# The common ancestor of +base_ref+ and HEAD — the "new code" boundary, the
|
|
50
|
+
# same merge-base semantics SonarQube/CodeScene use to scope a diff.
|
|
51
|
+
# @return [String, nil] the merge-base sha, or nil if it can't be resolved
|
|
52
|
+
# (base_ref unknown, shallow clone with no common history, outside a repo)
|
|
53
|
+
def merge_base(dir, base_ref)
|
|
54
|
+
out = capture(dir, "merge-base", base_ref, "HEAD")
|
|
55
|
+
out&.strip
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `git diff --name-status REF`: one "<status>\t<path>" line per changed file
|
|
59
|
+
# between +ref+ and the working tree (renames carry two paths). Empty string
|
|
60
|
+
# when nothing changed; nil on failure.
|
|
61
|
+
# @return [String, nil]
|
|
62
|
+
def diff_name_status(dir, ref)
|
|
63
|
+
capture(dir, "diff", "--name-status", ref)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `git diff --unified=0 REF`: a context-free unified diff between +ref+ and the
|
|
67
|
+
# working tree. Zero context means each hunk header's new-side range
|
|
68
|
+
# (`@@ -a,b +c,d @@`) is exactly the changed/added lines — what the gate needs
|
|
69
|
+
# to scope findings to the diff. Empty string when nothing changed; nil on failure.
|
|
70
|
+
# @return [String, nil]
|
|
71
|
+
def diff_unified_zero(dir, ref)
|
|
72
|
+
capture(dir, "diff", "--unified=0", ref)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Run a git subcommand, returning stdout, or nil on any failure.
|
|
76
|
+
def capture(dir, *args)
|
|
77
|
+
out, _, status = Open3.capture3("git", *args, chdir: dir)
|
|
78
|
+
status.success? ? out : nil
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|