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

Class Method Details

.boundaries_contributes?(run) ⇒ Boolean

Returns:

  • (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

Parameters:

  • root (String)

    absolute analysis root (should be the repo root)

  • files (Array<String>)

    absolute Ruby file paths to analyse

  • index (Index)

    resolved definition/reference index (drives dead code)

  • rails (RailsConventions)

    Rails entrypoint awareness for dead code

  • base_ref (String)

    base ref for the diff (e.g. "origin/main")

  • scope (Symbol)

    :diff (default) or :all

  • policy (Gate::Policy)

    the thresholds to apply

  • churn_since (String, nil) (defaults to: nil)

    churn window for the complexity analysis

Returns:



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

.runObject

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.message)
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