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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Flags
5
+ # The confidence-graded per-finding model for feature-flag STALENESS — this
6
+ # slice's first real use of Moult's protected per-finding confidence API. Where
7
+ # {Classification} grades recorded USAGE (a reference is a fact, so its confidence
8
+ # is null), staleness is a genuine judgement: given a flag's observed references
9
+ # and the state the provider snapshot reports for its key, how strong a candidate
10
+ # is it for removal?
11
+ #
12
+ # Like the static<->runtime coverage merge (see {Coverage::Resolver}), the
13
+ # snapshot is EVIDENCE, not proof. A flag is never asserted certainly stale or
14
+ # dead. The provider state (archived / disabled / fully rolled out) and the
15
+ # join result (absent — referenced in code, unknown to the provider) raise
16
+ # confidence; dynamic, non-literal keys in the codebase LOWER it, because the
17
+ # snapshot may be partial or the key resolved dynamically.
18
+ #
19
+ # {classify} is a pure function of the joined facts — no IO, no Prism, no clock —
20
+ # so it is pinned against hand-built inputs exactly like {ABC}, the coverage
21
+ # {Resolver}, the duplication {Confidence} model, {Boundaries::Severity}, and
22
+ # {Classification}. The statuses and confidence knees are deliberate v1
23
+ # heuristics; drift is a bug.
24
+ #
25
+ # Time-based "stale-since" decay (a flag untouched for N days) is deferred: it
26
+ # needs a +now+ clock and a threshold knee, so it would make this model impure.
27
+ # The snapshot's +exported_at+ and a flag's +updated_at+ are captured as evidence
28
+ # to seed it later, exactly as coverage captured +collected_at+ while deferring
29
+ # stale-detection.
30
+ module Staleness
31
+ # The provider explicitly retired the flag. The strongest removal candidate.
32
+ ARCHIVED = "archived"
33
+ # Referenced in code, but the snapshot has no such key (deleted/renamed in the
34
+ # provider, or managed elsewhere). Strong, but humbled by dynamic references.
35
+ ABSENT = "absent"
36
+ # Disabled in the provider (served to no one): the enabled branch is unreachable.
37
+ DISABLED = "disabled"
38
+ # Fully rolled out (enabled, no targeting — one variant served to all): the
39
+ # other branch is never taken.
40
+ ROLLED_OUT = "rolled_out"
41
+ # Enabled with targeting (serving multiple variations): actively evaluated,
42
+ # NOT a removal candidate.
43
+ ACTIVE = "active"
44
+
45
+ STATUSES = [ARCHIVED, ABSENT, DISABLED, ROLLED_OUT, ACTIVE].freeze
46
+
47
+ # Pinned confidence knees per status (removal-candidate strength).
48
+ ARCHIVED_CONFIDENCE = 0.9
49
+ ABSENT_CONFIDENCE = 0.7
50
+ ROLLED_OUT_CONFIDENCE = 0.6
51
+ DISABLED_CONFIDENCE = 0.5
52
+ ACTIVE_CONFIDENCE = 0.0
53
+
54
+ # Humility modifier: subtracted from an +absent+ candidate when the codebase
55
+ # has dynamic (non-literal-key) flag references — the key the static scan could
56
+ # not resolve may BE this one, so the snapshot's silence is less trustworthy.
57
+ DYNAMIC_REFERENCE_PENALTY = 0.2
58
+
59
+ # One auditable note behind a staleness judgement. Local to the flags slice
60
+ # (like {Classification::Reason}); categorical, so no +delta+.
61
+ Reason = Struct.new(:rule, :detail) do
62
+ def to_h
63
+ {rule: rule.to_s, detail: detail}
64
+ end
65
+ end
66
+
67
+ # The graded result: the staleness status, its confidence in [0, 1], and the
68
+ # reasons behind them.
69
+ Assessment = Struct.new(:status, :confidence, :reasons) do
70
+ def to_h
71
+ {status: status, confidence: confidence, reasons: reasons.map(&:to_h)}
72
+ end
73
+ end
74
+
75
+ module_function
76
+
77
+ # @param state [Snapshot::FlagState, nil] the provider's state for this key, or
78
+ # nil when the snapshot does not know the key (an +absent+ candidate)
79
+ # @param has_dynamic_references [Boolean] whether the codebase has any dynamic
80
+ # (non-literal-key) flag references (a snapshot-completeness caveat)
81
+ # @return [Assessment]
82
+ def classify(state:, has_dynamic_references: false)
83
+ return absent(has_dynamic_references) if state.nil?
84
+ return archived(state) if state.archived
85
+ return disabled(state) if state.enabled == false
86
+ return rolled_out(state) if state.enabled && !state.has_targeting
87
+ active(state)
88
+ end
89
+
90
+ def absent(has_dynamic_references)
91
+ reasons = [Reason.new(rule: :absent_from_provider,
92
+ detail: "referenced in code but unknown to the provider snapshot (deleted or renamed in the provider); a candidate for removal")]
93
+ confidence = ABSENT_CONFIDENCE
94
+ if has_dynamic_references
95
+ confidence -= DYNAMIC_REFERENCE_PENALTY
96
+ reasons << Reason.new(rule: :dynamic_references,
97
+ detail: "the codebase has dynamic (non-literal) flag keys, so the snapshot may be incomplete; confidence lowered")
98
+ end
99
+ assess(ABSENT, confidence, reasons)
100
+ end
101
+
102
+ def archived(state)
103
+ reasons = [Reason.new(rule: :provider_archived,
104
+ detail: "the provider marks this flag archived/retired; a strong candidate for removal")]
105
+ reasons << updated_at_reason(state)
106
+ assess(ARCHIVED, ARCHIVED_CONFIDENCE, reasons.compact)
107
+ end
108
+
109
+ def disabled(state)
110
+ reasons = [Reason.new(rule: :provider_disabled,
111
+ detail: "disabled in the provider (served to no one); the enabled branch is unreachable — a candidate for removal")]
112
+ reasons << updated_at_reason(state)
113
+ assess(DISABLED, DISABLED_CONFIDENCE, reasons.compact)
114
+ end
115
+
116
+ def rolled_out(state)
117
+ reasons = [Reason.new(rule: :fully_rolled_out,
118
+ detail: "enabled with no targeting (one variant served to all); the other branch is never taken — a candidate for removal")]
119
+ reasons << updated_at_reason(state)
120
+ assess(ROLLED_OUT, ROLLED_OUT_CONFIDENCE, reasons.compact)
121
+ end
122
+
123
+ def active(_state)
124
+ reasons = [Reason.new(rule: :active,
125
+ detail: "enabled with targeting (serving multiple variations); actively evaluated — not a removal candidate")]
126
+ assess(ACTIVE, ACTIVE_CONFIDENCE, reasons)
127
+ end
128
+
129
+ # An evidence note for a captured last-modified timestamp (deferred time-decay
130
+ # seed). nil when the snapshot recorded none, so it is compacted out.
131
+ def updated_at_reason(state)
132
+ return nil unless state.updated_at
133
+ Reason.new(rule: :last_modified, detail: "provider last modified this flag at #{state.updated_at}")
134
+ end
135
+
136
+ def assess(status, confidence, reasons)
137
+ Assessment.new(status: status, confidence: clamp(confidence), reasons: reasons)
138
+ end
139
+
140
+ def clamp(value)
141
+ value.clamp(0.0, 1.0).round(2)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # Orchestrates the feature-flag analysis: it asks the {FlagScanner} for every
5
+ # OpenFeature flag-evaluation call site, groups them by flag key, attributes each
6
+ # site to its enclosing method (best-effort, for the cross-analysis join), and
7
+ # grades each group through the pure {Flags::Classification} model. The result is
8
+ # a {FlagsReport} cataloguing flag USAGE.
9
+ #
10
+ # When a provider snapshot is supplied (the static<->provider merge, the flags
11
+ # analogue of the static<->runtime coverage merge), it ALSO joins each flag key to
12
+ # the provider's recorded state and grades a confidence-graded {Staleness}
13
+ # candidate — the first real use of the per-finding confidence slot in this slice.
14
+ # The snapshot is evidence, never proof; nothing here asserts a flag is certainly
15
+ # stale or dead.
16
+ #
17
+ # This is the only layer that joins the facts to symbols and to the provider;
18
+ # {Classification} and {Staleness} stay pure functions of the observed signals so
19
+ # they can be pinned in isolation, {FlagScanner} stays the sole keeper of the
20
+ # OpenFeature call shape, and {Snapshot} the sole keeper of the export format.
21
+ module Flags
22
+ module_function
23
+
24
+ # @param root [String] absolute analysis root
25
+ # @param files [Array<String>] absolute Ruby file paths to scan
26
+ # @param snapshot [Snapshot::FlagSet, nil] a merged provider snapshot; when given,
27
+ # each finding gains a confidence-graded staleness candidate joined on flag_key
28
+ # @return [FlagsReport]
29
+ def build_report(root:, files:, git_ref: nil, generated_at: nil, snapshot: nil)
30
+ sites = files.flat_map { |abs| scan(abs, root) }
31
+ methods = MethodIndex.new(root: root, files: files)
32
+
33
+ literal, dynamic = sites.partition { |s| !s.flag_key.nil? }
34
+ has_dynamic = dynamic.size.positive?
35
+ findings = literal.group_by(&:flag_key).map { |key, group| finding_for(key, group, methods, snapshot, has_dynamic) }
36
+ # With a snapshot, strongest staleness candidate first (then refs, then key);
37
+ # without, most-referenced first. Either way alphabetical by key breaks ties so
38
+ # output is stable.
39
+ findings.sort_by! do |f|
40
+ f.staleness ? [-f.staleness.confidence, -f.reference_count, f.flag_key] : [-f.reference_count, f.flag_key]
41
+ end
42
+
43
+ FlagsReport.new(
44
+ root: root,
45
+ findings: findings,
46
+ dynamic_references: dynamic.size,
47
+ git_ref: git_ref,
48
+ generated_at: generated_at,
49
+ provider_source: snapshot&.source
50
+ )
51
+ end
52
+
53
+ def scan(abs, root)
54
+ FlagScanner.scan_file(abs, SymbolId.relative_path(abs, root))
55
+ rescue
56
+ []
57
+ end
58
+
59
+ def finding_for(key, sites, methods, snapshot = nil, has_dynamic = false)
60
+ assessment = Classification.classify(
61
+ value_types: sites.map(&:value_type),
62
+ default_values: sites.map(&:default_value)
63
+ )
64
+ occurrences = sites
65
+ .sort_by { |s| [s.path, s.line] }
66
+ .map { |s| FlagsReport::Occurrence.new(symbol_id: methods.symbol_id_at(s.path, s.line), path: s.path, line: s.line, method_name: s.method_name) }
67
+ staleness = staleness_for(key, snapshot, has_dynamic)
68
+ FlagsReport::Finding.new(
69
+ flag_key: key,
70
+ value_type: assessment.value_type,
71
+ reference_count: assessment.reference_count,
72
+ default_values: assessment.default_values,
73
+ reasons: assessment.reasons,
74
+ occurrences: occurrences,
75
+ staleness: staleness
76
+ )
77
+ end
78
+
79
+ # The staleness candidate for a key, joined on the literal flag_key (the flags
80
+ # join key, mirroring how coverage joins on symbol_id). nil when no snapshot was
81
+ # supplied, leaving the finding byte-for-byte v1-identical.
82
+ def staleness_for(key, snapshot, has_dynamic)
83
+ return nil unless snapshot
84
+ Staleness.classify(state: snapshot.state_for(key), has_dynamic_references: has_dynamic)
85
+ end
86
+
87
+ # Best-effort line -> enclosing-method resolution, reusing the Prism {Parser} so
88
+ # the minted ids are byte-identical to the hotspots/deadcode/duplication join
89
+ # keys. We attribute a call site to the innermost method whose span contains its
90
+ # line; files are parsed lazily and memoised. A reference outside any method
91
+ # (top-level code) resolves to nil. (Mirrors {Duplication::MethodIndex}.)
92
+ class MethodIndex
93
+ def initialize(root:, files:)
94
+ @abs_by_rel = files.to_h { |abs| [SymbolId.relative_path(abs, root), abs] }
95
+ @cache = {}
96
+ end
97
+
98
+ # @return [String, nil] symbol_id of the innermost containing method, or nil
99
+ def symbol_id_at(rel_path, line)
100
+ method = enclosing_method(rel_path, line)
101
+ return nil unless method
102
+ SymbolId.for(path: rel_path, start_line: method.span.start_line, fqname: method.name)
103
+ end
104
+
105
+ private
106
+
107
+ def enclosing_method(rel_path, line)
108
+ methods_for(rel_path)
109
+ .select { |m| line.between?(m.span.start_line, m.span.end_line) }
110
+ .min_by { |m| m.span.end_line - m.span.start_line }
111
+ end
112
+
113
+ def methods_for(rel_path)
114
+ @cache[rel_path] ||= parse(rel_path)
115
+ end
116
+
117
+ def parse(rel_path)
118
+ abs = @abs_by_rel[rel_path]
119
+ return [] unless abs
120
+ Parser.parse_file(abs)
121
+ rescue
122
+ []
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ require_relative "flags/classification"
129
+ require_relative "flags/staleness"
130
+ require_relative "flags/snapshot"
131
+ require_relative "flags_report"
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # The serialized result model for `moult flags` (schema/flags.schema.json),
5
+ # sibling to {DuplicationReport}, {DeadCodeReport}, {CoverageReport},
6
+ # {HealthReport} and {BoundariesReport}. It owns its own JSON envelope and leaves
7
+ # the other protected contracts untouched.
8
+ #
9
+ # Each {Finding} is one flag key and the call sites referencing it. Without a
10
+ # provider snapshot it carries +confidence: null+ (a flag reference is a recorded
11
+ # fact) and a {Flags::Classification} signal — the value_type, reference count, and
12
+ # observed default value(s) — and serializes at schema_version 1.
13
+ #
14
+ # When a provider snapshot is merged (the static<->provider merge) each finding
15
+ # ALSO carries a confidence-graded {Flags::Staleness} candidate (status +
16
+ # confidence + reasons), the report carries an analysis.provider provenance block,
17
+ # and the summary a by_staleness_status tally; the envelope reports schema_version
18
+ # 2. The bump is purely additive: with no snapshot the v2-only blocks are omitted
19
+ # and the output is byte-for-byte identical to v1. The snapshot is evidence, never
20
+ # proof — nothing here asserts a flag is certainly stale or dead.
21
+ class FlagsReport
22
+ # The serialized shape's two additive versions. v1 = usage only; v2 = usage +
23
+ # joined staleness candidates (--provider). Bump either only on a breaking change.
24
+ SCHEMA_VERSION = 1
25
+ SCHEMA_VERSION_WITH_PROVIDER = 2
26
+
27
+ # One reference site of a flag. +symbol_id+ is the best-effort enclosing-method
28
+ # join key (shared across contracts), nil for a top-level reference. +line+ is
29
+ # the call-site line; +method+ is the OpenFeature fetch method used (it implies
30
+ # the value type and whether the _details variant was called).
31
+ Occurrence = Struct.new(:symbol_id, :path, :line, :method_name) do
32
+ def to_h
33
+ {symbol_id: symbol_id, path: path, line: line, method: method_name}
34
+ end
35
+ end
36
+
37
+ # One flag key. Carries its classification (value_type / reference_count /
38
+ # default_values) and reasons so the catalogue is auditable. +staleness+ is a
39
+ # {Flags::Staleness::Assessment} when a provider snapshot was merged, else nil.
40
+ # +confidence+ is the staleness candidate's confidence when graded, else null (a
41
+ # reference alone is a fact, the signal is the classification).
42
+ Finding = Struct.new(:flag_key, :value_type, :reference_count, :default_values, :reasons, :occurrences, :staleness) do
43
+ def to_h
44
+ h = {
45
+ category: Flags::Classification::CATEGORY,
46
+ confidence: staleness&.confidence,
47
+ flag_key: flag_key,
48
+ value_type: value_type,
49
+ reference_count: reference_count,
50
+ default_values: default_values,
51
+ reasons: reasons.map(&:to_h),
52
+ occurrences: occurrences.map(&:to_h)
53
+ }
54
+ # Additive: the staleness block appears only when graded, leaving the v1
55
+ # finding byte-for-byte unchanged (confidence stays null, no extra key).
56
+ h[:staleness] = staleness.to_h if staleness
57
+ h
58
+ end
59
+ end
60
+
61
+ attr_reader :root, :findings, :dynamic_references, :git_ref, :generated_at, :provider_source
62
+
63
+ # @param root [String] absolute analysis root
64
+ # @param findings [Array<Finding>] ranked, strongest staleness candidate (or
65
+ # most-referenced, without a snapshot) first
66
+ # @param dynamic_references [Integer] flag-evaluation calls whose key was not a
67
+ # literal (counted, not catalogued — a static scan cannot resolve the key)
68
+ # @param provider_source [Flags::Snapshot::Source, nil] provenance of a merged
69
+ # provider snapshot; its presence selects schema_version 2 and the v2-only blocks
70
+ def initialize(root:, findings:, dynamic_references: 0, git_ref: nil, generated_at: nil, provider_source: nil)
71
+ @root = root
72
+ @findings = findings
73
+ @dynamic_references = dynamic_references
74
+ @git_ref = git_ref
75
+ @generated_at = generated_at
76
+ @provider_source = provider_source
77
+ end
78
+
79
+ # @return [Hash] aggregate counts across all flags
80
+ def summary
81
+ base = {
82
+ flags: findings.size,
83
+ references: findings.sum { |f| f.occurrences.size },
84
+ dynamic_references: dynamic_references,
85
+ by_value_type: tally { |f| f.value_type }
86
+ }
87
+ # v2-only: the staleness-status tally appears only when a snapshot was merged.
88
+ base[:by_staleness_status] = staleness_tally if provider_source
89
+ base
90
+ end
91
+
92
+ def to_h
93
+ {
94
+ schema_version: provider_source ? SCHEMA_VERSION_WITH_PROVIDER : SCHEMA_VERSION,
95
+ tool: {name: "moult", version: Moult::VERSION},
96
+ analysis: analysis,
97
+ summary: summary,
98
+ findings: findings.map(&:to_h)
99
+ }
100
+ end
101
+
102
+ private
103
+
104
+ # Built incrementally so the provider provenance is appended only when present,
105
+ # leaving the v1 analysis block byte-for-byte unchanged.
106
+ def analysis
107
+ block = {
108
+ root: root,
109
+ git_ref: git_ref,
110
+ generated_at: generated_at,
111
+ scanner: {
112
+ target: FlagScanner::TARGET,
113
+ sdk_gem: FlagScanner::SDK_GEM,
114
+ client_builder: FlagScanner::CLIENT_BUILDER
115
+ }
116
+ }
117
+ block[:provider] = provider_source.to_h if provider_source
118
+ block
119
+ end
120
+
121
+ # Flag count keyed by staleness status (one per flag, not reference-weighted).
122
+ def staleness_tally
123
+ findings.each_with_object(Hash.new(0)) do |finding, acc|
124
+ acc[finding.staleness.status] += 1 if finding.staleness
125
+ end
126
+ end
127
+
128
+ # Count flags grouped by the yielded key, reference-weighted so the totals match
129
+ # the +references+ count.
130
+ def tally
131
+ findings.each_with_object(Hash.new(0)) do |finding, acc|
132
+ acc[yield(finding)] += finding.occurrences.size
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # JSON rendering of a {BoundariesReport}. A thin pass-through of the report's own
8
+ # +to_h+ so the serialized shape cannot drift from the table formatter or the
9
+ # contract.
10
+ module BoundariesJson
11
+ module_function
12
+
13
+ # @param report [BoundariesReport]
14
+ # @return [String]
15
+ def render(report)
16
+ JSON.pretty_generate(report.to_h)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable table of architecture-boundary violations. Renders from the same
6
+ # {BoundariesReport} as the JSON formatter so the two cannot disagree. Sorting
7
+ # already happened in {Boundaries}; this layer owns column formatting only.
8
+ #
9
+ # The heading is deliberate: these are recorded packwerk violations, classified
10
+ # by severity — never a claim that the code is certainly wrong.
11
+ module BoundariesTable
12
+ MAX_FILES = 3
13
+
14
+ module_function
15
+
16
+ # @param report [BoundariesReport]
17
+ # @return [String]
18
+ def render(report)
19
+ return "Not a packwerk project (no packwerk.yml): no architecture boundaries to check." unless report.configured
20
+
21
+ findings = report.findings
22
+ return "No architecture-boundary violations recorded." if findings.empty?
23
+
24
+ headers = %w[SEV TYPE REFERENCING DEFINING CONSTANT FILES]
25
+ rows = findings.map { |f| row(f) }
26
+ [heading(report.summary), "", TextTable.render(headers, rows)].join("\n")
27
+ end
28
+
29
+ def heading(summary)
30
+ by_sev = %w[high medium low].filter_map { |s| "#{summary[:by_severity][s]} #{s}" if summary[:by_severity][s].to_i.positive? }
31
+ "Architecture-boundary violations (packwerk, recorded — not certainties): " \
32
+ "#{summary[:findings]} groups, #{summary[:violations]} violations (#{by_sev.join(", ")})"
33
+ end
34
+
35
+ def row(finding)
36
+ [
37
+ finding.severity,
38
+ finding.violation_type,
39
+ finding.referencing_package,
40
+ finding.defining_package,
41
+ finding.constant,
42
+ files(finding.occurrences)
43
+ ]
44
+ end
45
+
46
+ def files(occurrences)
47
+ shown = occurrences.first(MAX_FILES).map(&:path)
48
+ extra = occurrences.size - shown.size
49
+ extra.positive? ? "#{shown.join(", ")} (+#{extra} more)" : shown.join(", ")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # Emits the typed coverage-map JSON contract (schema/coverage.schema.json),
8
+ # straight from {CoverageReport#to_h} so the serialized shape cannot drift.
9
+ module CoverageJson
10
+ module_function
11
+
12
+ # @param report [CoverageReport]
13
+ # @return [String] pretty-printed JSON
14
+ def render(report)
15
+ JSON.pretty_generate(report.to_h)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable runtime coverage map. Renders from the same
6
+ # {CoverageReport} as the JSON formatter so the two cannot disagree; sorting
7
+ # already happened in {CoverageReport.build}.
8
+ module CoverageTable
9
+ HEADERS = ["RUNTIME", "KIND", "SYMBOL", "LOCATION"].freeze
10
+ GUTTER = " "
11
+
12
+ module_function
13
+
14
+ # @param report [CoverageReport]
15
+ # @return [String]
16
+ def render(report)
17
+ entries = report.entries
18
+ return "No symbols found." if entries.empty?
19
+
20
+ rows = entries.map { |e| row(e, report.root) }
21
+ [heading(report.summary), "", table(rows)].join("\n")
22
+ end
23
+
24
+ def heading(summary)
25
+ "Runtime coverage map: #{summary[:hot]} hot, #{summary[:cold]} cold, #{summary[:untracked]} untracked"
26
+ end
27
+
28
+ def row(entry, _root)
29
+ [
30
+ entry.runtime.to_s,
31
+ entry.kind.to_s,
32
+ entry.name.to_s,
33
+ location(entry)
34
+ ]
35
+ end
36
+
37
+ def location(entry)
38
+ # symbol_id is "<path>:<start_line>:<fqname>"; the path:line prefix is the
39
+ # most useful location and avoids re-deriving it.
40
+ path, line, _ = entry.symbol_id.split(":", 3)
41
+ "#{path}:#{line}"
42
+ end
43
+
44
+ def table(rows)
45
+ widths = column_widths(rows)
46
+ ([HEADERS] + rows).map { |cells| format_row(cells, widths) }.join("\n")
47
+ end
48
+
49
+ def column_widths(rows)
50
+ HEADERS.each_index.map do |col|
51
+ ([HEADERS[col]] + rows.map { |r| r[col] }).map(&:length).max
52
+ end
53
+ end
54
+
55
+ def format_row(cells, widths)
56
+ cells.each_with_index.map { |cell, col| cell.ljust(widths[col]) }.join(GUTTER).rstrip
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # Emits the typed dead-code JSON contract (schema/deadcode.schema.json).
8
+ # Renders straight from {DeadCodeReport#to_h} so the serialized shape cannot
9
+ # drift from the result model.
10
+ module DeadCodeJson
11
+ module_function
12
+
13
+ # @param report [DeadCodeReport]
14
+ # @return [String] pretty-printed JSON
15
+ def render(report)
16
+ JSON.pretty_generate(report.to_h)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable table of dead-code candidates. Renders from the same
6
+ # {DeadCodeReport} as the JSON formatter so the two cannot disagree. Sorting
7
+ # already happened in {DeadCode}; this layer owns column formatting only.
8
+ #
9
+ # The heading is deliberate: these are confidence-graded candidates, never
10
+ # certainties.
11
+ module DeadCodeTable
12
+ # Only the CONF column (col 0) is right-aligned.
13
+ RIGHT_ALIGNED = [0].freeze
14
+
15
+ module_function
16
+
17
+ # @param report [DeadCodeReport]
18
+ # @return [String]
19
+ def render(report)
20
+ findings = report.findings
21
+ return "No dead-code candidates found." if findings.empty?
22
+
23
+ # The RUNTIME column only appears when coverage was merged, so output
24
+ # without --coverage is byte-for-byte unchanged from Phase 2.
25
+ runtime = findings.any? { |f| !f.runtime.nil? }
26
+ headers = columns(runtime)
27
+ rows = findings.map { |f| row(f, runtime) }
28
+ [heading(findings.size), "", TextTable.render(headers, rows, right_aligned: RIGHT_ALIGNED)].join("\n")
29
+ end
30
+
31
+ def columns(runtime)
32
+ cols = ["CONF", "KIND"]
33
+ cols << "RUNTIME" if runtime
34
+ cols + ["SYMBOL", "LOCATION", "TOP REASON"]
35
+ end
36
+
37
+ def heading(count)
38
+ "Dead-code candidates (confidence-graded — not certainties): #{count} findings"
39
+ end
40
+
41
+ def row(finding, runtime)
42
+ cells = [conf(finding.confidence), finding.kind.to_s]
43
+ cells << (finding.runtime&.to_s || "-") if runtime
44
+ cells + [finding.name.to_s, location(finding), top_reason(finding)]
45
+ end
46
+
47
+ def location(finding)
48
+ span = finding.span
49
+ line = span&.start_line
50
+ line ? "#{finding.path}:#{line}" : finding.path.to_s
51
+ end
52
+
53
+ # The most informative reason is the last applied non-base adjustment;
54
+ # fall back to the base reason for a bare candidate.
55
+ def top_reason(finding)
56
+ reasons = finding.reasons.reject { |r| r.rule == :base_score }
57
+ chosen = reasons.last || finding.reasons.first
58
+ chosen ? chosen.detail.to_s : "-"
59
+ end
60
+
61
+ def conf(value)
62
+ format("%.2f", value)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # Emits the typed duplication JSON contract (schema/duplication.schema.json).
8
+ # Renders straight from {DuplicationReport#to_h} so the serialized shape cannot
9
+ # drift from the result model.
10
+ module DuplicationJson
11
+ module_function
12
+
13
+ # @param report [DuplicationReport]
14
+ # @return [String] pretty-printed JSON
15
+ def render(report)
16
+ JSON.pretty_generate(report.to_h)
17
+ end
18
+ end
19
+ end
20
+ end