Module: Moult::Gate
- Defined in:
- lib/moult/gate.rb,
lib/moult/gate/config.rb,
lib/moult/gate/policy.rb,
lib/moult/gate/evaluation.rb
Overview
Orchestrates the diff-aware PR risk gate — the capstone of the static layer and the gem-level core of what later becomes the GitHub App.
It reuses Health's composer discipline: each signal analysis runs inside its own rescue so one failure degrades that rule (evaluated: false) rather than crashing the gate. It then scopes every finding to the diff (via Diff), extracts pure observations, and hands them to the pinned Evaluation model together with the recorded Policy. This file is the only layer that does IO and knows where the signals come from; Policy/Evaluation stay pure functions so they can be pinned in isolation.
The gate consumes signals and renders a verdict; it never mutates a signal contract, and the two protected APIs are untouched. The verdict is an auditable application of an explicit policy over confidence-graded candidates — never a claim that code is certainly wrong or dead.
Defined Under Namespace
Modules: Config, Evaluation Classes: Policy, Run
Constant Summary collapse
- KNOWN_COMPONENTS =
Fixed component order so the provenance block is stable.
%w[complexity dead_code duplication boundaries].freeze
Class Method Summary collapse
- .boundaries_contributes?(run) ⇒ Boolean
- .build_report(root:, files:, index:, rails:, base_ref:, scope:, policy:, git_ref: nil, generated_at: nil, churn_since: nil) ⇒ GateReport
- .component_diagnostic(name, run) ⇒ Object
- .component_views(runs) ⇒ Object
-
.diagnostics(runs) ⇒ Object
---- provenance -----------------------------------------------------------.
-
.gated(observations, policy) ⇒ Object
Drop excluded-path observations; nil (a skipped analysis) passes through.
-
.observe(runs, diff, policy) ⇒ Object
Scope every analysis's findings to the diff, into pure observations, then drop any under an excluded path (test/spec) so the gate judges production code.
-
.run ⇒ Object
Run one analysis in isolation (mirrors Health.run).
-
.run_analyses(root:, files:, index:, rails:, churn_since:) ⇒ Object
Run each signal analysis in isolation (the composer discipline).
-
.scope_boundaries(run, diff) ⇒ Object
Boundaries are file-keyed (null symbol_id), so they scope at PATH granularity.
-
.scope_complexity(run, diff) ⇒ Object
nil signals a skipped analysis (errored): its rule is not evaluated.
- .scope_dead_code(run, diff) ⇒ Object
-
.scope_duplication(run, diff) ⇒ Object
One observation per clone GROUP touching the diff (mass is a group property); attributed to its first in-diff occurrence.
Class Method Details
.boundaries_contributes?(run) ⇒ Boolean
162 163 164 |
# File 'lib/moult/gate.rb', line 162 def boundaries_contributes?(run) run.ok? && run.value.configured end |
.build_report(root:, files:, index:, rails:, base_ref:, scope:, policy:, git_ref: nil, generated_at: nil, churn_since: nil) ⇒ GateReport
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/moult/gate.rb', line 41 def build_report(root:, files:, index:, rails:, base_ref:, scope:, policy:, git_ref: nil, generated_at: nil, churn_since: nil) diff = Diff.compute(root: root, base_ref: base_ref, scope: scope) runs = run_analyses(root: root, files: files, index: index, rails: rails, churn_since: churn_since) observations = observe(runs, diff, policy) GateReport.new( root: root, base_ref: diff.base_ref, merge_base: diff.merge_base, scope: diff.scope, components: component_views(runs), policy: policy, evaluation: Evaluation.evaluate(observations: observations, policy: policy), git_ref: git_ref, generated_at: generated_at ) end |
.component_diagnostic(name, run) ⇒ Object
187 188 189 190 191 192 |
# File 'lib/moult/gate.rb', line 187 def component_diagnostic(name, run) return run.error if run.error return "not a packwerk project (no packwerk.yml)" if name == "boundaries" "analysis produced no result" end |
.component_views(runs) ⇒ Object
179 180 181 182 183 184 185 |
# File 'lib/moult/gate.rb', line 179 def component_views(runs) KNOWN_COMPONENTS.map do |name| present = (name == "boundaries") ? boundaries_contributes?(runs[name]) : runs[name].ok? diagnostic = present ? nil : component_diagnostic(name, runs[name]) GateReport::Component.new(name: name, present: present, diagnostic: diagnostic) end end |
.diagnostics(runs) ⇒ Object
---- provenance -----------------------------------------------------------
168 169 170 171 172 173 174 175 176 177 |
# File 'lib/moult/gate.rb', line 168 def diagnostics(runs) diags = {} %w[complexity dead_code duplication].each do |name| diags[name.to_sym] = runs[name].error if runs[name].error end unless boundaries_contributes?(runs["boundaries"]) diags[:boundaries] = runs["boundaries"].error || "not a packwerk project (no packwerk.yml)" end diags end |
.gated(observations, policy) ⇒ Object
Drop excluded-path observations; nil (a skipped analysis) passes through.
86 87 88 89 90 |
# File 'lib/moult/gate.rb', line 86 def gated(observations, policy) return nil if observations.nil? observations.reject { |o| policy.excluded?(o.path) } end |
.observe(runs, diff, policy) ⇒ Object
Scope every analysis's findings to the diff, into pure observations, then drop any under an excluded path (test/spec) so the gate judges production code.
75 76 77 78 79 80 81 82 83 |
# File 'lib/moult/gate.rb', line 75 def observe(runs, diff, policy) Evaluation::Observations.new( complexity: gated(scope_complexity(runs["complexity"], diff), policy), dead_code: gated(scope_dead_code(runs["dead_code"], diff), policy), duplication: gated(scope_duplication(runs["duplication"], diff), policy), boundaries: gated(scope_boundaries(runs["boundaries"], diff), policy), diagnostics: diagnostics(runs) ) end |
.run ⇒ Object
Run one analysis in isolation (mirrors Health.run).
93 94 95 96 97 |
# File 'lib/moult/gate.rb', line 93 def run Run.new(value: yield, error: nil) rescue => e Run.new(value: nil, error: e.) end |
.run_analyses(root:, files:, index:, rails:, churn_since:) ⇒ Object
Run each signal analysis in isolation (the composer discipline).
61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/moult/gate.rb', line 61 def run_analyses(root:, files:, index:, rails:, churn_since:) # Churn is only collected to satisfy {Scoring}; the complexity rule reads # each method's ABC + span, which are churn-independent. churn = Churn.collect(root: root, since: churn_since || Churn::DEFAULT_SINCE) { "complexity" => run { Scoring.build_report(root: root, files: files, churn: churn) }, "dead_code" => run { DeadCode.build_report(root: root, files: files, index: index, rails: rails) }, "duplication" => run { Duplication.build_report(root: root, files: files) }, "boundaries" => run { Boundaries.build_report(root: root) } } end |
.scope_boundaries(run, diff) ⇒ Object
Boundaries are file-keyed (null symbol_id), so they scope at PATH granularity. Skipped unless the project is actually packwerk-configured.
147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/moult/gate.rb', line 147 def scope_boundaries(run, diff) return nil unless boundaries_contributes?(run) run.value.findings.flat_map do |finding| finding.occurrences.filter_map do |occ| next unless diff.includes_path?(occ.path) Evaluation::BoundaryObs.new( symbol_id: nil, path: occ.path, line: nil, severity: finding.severity, violation_type: finding.violation_type ) end end end |
.scope_complexity(run, diff) ⇒ Object
nil signals a skipped analysis (errored): its rule is not evaluated.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/moult/gate.rb', line 102 def scope_complexity(run, diff) return nil unless run.ok? run.value.hotspots.flat_map do |hotspot| hotspot.methods.filter_map do |method| next unless diff.in_diff?(path: hotspot.path, start_line: method.span.start_line, end_line: method.span.end_line) Evaluation::ComplexityObs.new( symbol_id: method.symbol_id, path: hotspot.path, line: method.span.start_line, abc: method.abc ) end end end |
.scope_dead_code(run, diff) ⇒ Object
117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/moult/gate.rb', line 117 def scope_dead_code(run, diff) return nil unless run.ok? run.value.findings.filter_map do |finding| next unless diff.in_diff?(path: finding.path, start_line: finding.span.start_line, end_line: finding.span.end_line) Evaluation::DeadCodeObs.new( symbol_id: finding.symbol_id, path: finding.path, line: finding.span.start_line, confidence: finding.confidence ) end end |
.scope_duplication(run, diff) ⇒ Object
One observation per clone GROUP touching the diff (mass is a group property); attributed to its first in-diff occurrence.
132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/moult/gate.rb', line 132 def scope_duplication(run, diff) return nil unless run.ok? run.value.findings.filter_map do |finding| occ = finding.occurrences.find { |o| diff.in_diff?(path: o.path, start_line: o.line, end_line: o.line) } next unless occ Evaluation::DuplicationObs.new( symbol_id: occ.symbol_id, path: occ.path, line: occ.line, mass: finding.mass ) end end |