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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/LICENSE.txt +201 -0
  4. data/NOTICE +4 -0
  5. data/README.md +331 -0
  6. data/exe/moult +6 -0
  7. data/lib/moult/abc.rb +133 -0
  8. data/lib/moult/boundaries/packwerk.rb +114 -0
  9. data/lib/moult/boundaries/severity.rb +87 -0
  10. data/lib/moult/boundaries.rb +77 -0
  11. data/lib/moult/boundaries_report.rb +106 -0
  12. data/lib/moult/churn.rb +52 -0
  13. data/lib/moult/cli/boundaries_command.rb +83 -0
  14. data/lib/moult/cli/coverage_command.rb +101 -0
  15. data/lib/moult/cli/dead_code_command.rb +112 -0
  16. data/lib/moult/cli/duplication_command.rb +92 -0
  17. data/lib/moult/cli/flags_command.rb +95 -0
  18. data/lib/moult/cli/gate_command.rb +113 -0
  19. data/lib/moult/cli/health_command.rb +117 -0
  20. data/lib/moult/cli/hotspots_command.rb +104 -0
  21. data/lib/moult/cli.rb +102 -0
  22. data/lib/moult/clones.rb +91 -0
  23. data/lib/moult/cloud_upload.rb +29 -0
  24. data/lib/moult/confidence/rules.rb +128 -0
  25. data/lib/moult/confidence.rb +106 -0
  26. data/lib/moult/coverage/resolver.rb +56 -0
  27. data/lib/moult/coverage.rb +176 -0
  28. data/lib/moult/coverage_report.rb +98 -0
  29. data/lib/moult/dead_code.rb +119 -0
  30. data/lib/moult/dead_code_report.rb +65 -0
  31. data/lib/moult/diff.rb +177 -0
  32. data/lib/moult/discovery.rb +38 -0
  33. data/lib/moult/duplication/confidence.rb +92 -0
  34. data/lib/moult/duplication.rb +112 -0
  35. data/lib/moult/duplication_report.rb +89 -0
  36. data/lib/moult/flag_scanner.rb +150 -0
  37. data/lib/moult/flags/classification.rb +79 -0
  38. data/lib/moult/flags/snapshot.rb +162 -0
  39. data/lib/moult/flags/staleness.rb +145 -0
  40. data/lib/moult/flags.rb +131 -0
  41. data/lib/moult/flags_report.rb +136 -0
  42. data/lib/moult/formatters/boundaries_json.rb +20 -0
  43. data/lib/moult/formatters/boundaries_table.rb +53 -0
  44. data/lib/moult/formatters/coverage_json.rb +19 -0
  45. data/lib/moult/formatters/coverage_table.rb +60 -0
  46. data/lib/moult/formatters/dead_code_json.rb +20 -0
  47. data/lib/moult/formatters/dead_code_table.rb +66 -0
  48. data/lib/moult/formatters/duplication_json.rb +20 -0
  49. data/lib/moult/formatters/duplication_table.rb +55 -0
  50. data/lib/moult/formatters/flags_json.rb +20 -0
  51. data/lib/moult/formatters/flags_table.rb +76 -0
  52. data/lib/moult/formatters/gate_github.rb +52 -0
  53. data/lib/moult/formatters/gate_json.rb +20 -0
  54. data/lib/moult/formatters/gate_message.rb +19 -0
  55. data/lib/moult/formatters/gate_sarif.rb +78 -0
  56. data/lib/moult/formatters/gate_table.rb +71 -0
  57. data/lib/moult/formatters/health_json.rb +20 -0
  58. data/lib/moult/formatters/health_table.rb +80 -0
  59. data/lib/moult/formatters/json.rb +23 -0
  60. data/lib/moult/formatters/table.rb +70 -0
  61. data/lib/moult/formatters/text_table.rb +39 -0
  62. data/lib/moult/gate/config.rb +55 -0
  63. data/lib/moult/gate/evaluation.rb +172 -0
  64. data/lib/moult/gate/policy.rb +103 -0
  65. data/lib/moult/gate.rb +199 -0
  66. data/lib/moult/gate_report.rb +97 -0
  67. data/lib/moult/git.rb +83 -0
  68. data/lib/moult/health/score.rb +291 -0
  69. data/lib/moult/health.rb +320 -0
  70. data/lib/moult/health_report.rb +97 -0
  71. data/lib/moult/index.rb +228 -0
  72. data/lib/moult/parser.rb +101 -0
  73. data/lib/moult/rails_conventions.rb +124 -0
  74. data/lib/moult/report.rb +114 -0
  75. data/lib/moult/scoring.rb +82 -0
  76. data/lib/moult/span.rb +17 -0
  77. data/lib/moult/symbol_id.rb +30 -0
  78. data/lib/moult/symbol_scanner.rb +100 -0
  79. data/lib/moult/version.rb +5 -0
  80. data/lib/moult.rb +84 -0
  81. data/schema/boundaries.schema.json +125 -0
  82. data/schema/common.schema.json +76 -0
  83. data/schema/coverage.schema.json +83 -0
  84. data/schema/deadcode.schema.json +106 -0
  85. data/schema/duplication.schema.json +128 -0
  86. data/schema/flags.schema.json +157 -0
  87. data/schema/gate.schema.json +165 -0
  88. data/schema/health.schema.json +157 -0
  89. data/schema/hotspots.schema.json +106 -0
  90. 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