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,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
|
data/lib/moult/flags.rb
ADDED
|
@@ -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
|