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,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult coverage [PATH] --coverage FILE` — a per-symbol hot/cold/untracked
|
|
9
|
+
# map resolved from a local coverage file. Thin layer: parse options, build
|
|
10
|
+
# the index, load the dataset, drive {CoverageReport.build}, format. The map
|
|
11
|
+
# is diagnostic; it makes no dead-code claim (see `moult deadcode --coverage`
|
|
12
|
+
# for the confidence merge).
|
|
13
|
+
class CoverageCommand
|
|
14
|
+
VALID_FORMATS = %i[auto simplecov coverage].freeze
|
|
15
|
+
|
|
16
|
+
# @return [Integer] process exit status
|
|
17
|
+
def run(argv)
|
|
18
|
+
options = parse(argv)
|
|
19
|
+
return puts_help(options) if options[:help]
|
|
20
|
+
|
|
21
|
+
unless options[:coverage]
|
|
22
|
+
warn "moult: coverage requires --coverage PATH"
|
|
23
|
+
warn @parser
|
|
24
|
+
return 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
root = File.expand_path(options[:path])
|
|
28
|
+
unless File.exist?(root)
|
|
29
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
30
|
+
return 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
puts render(analyze(root, options), options)
|
|
34
|
+
0
|
|
35
|
+
rescue OptionParser::ParseError => e
|
|
36
|
+
warn "moult: #{e.message}"
|
|
37
|
+
1
|
|
38
|
+
rescue => e
|
|
39
|
+
warn "moult: #{e.message}"
|
|
40
|
+
1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def parse(argv)
|
|
46
|
+
options = {format: :table, coverage: nil, coverage_format: :auto, quiet: false}
|
|
47
|
+
@parser = OptionParser.new do |o|
|
|
48
|
+
o.banner = "Usage: moult coverage [PATH] --coverage FILE [options]"
|
|
49
|
+
o.separator ""
|
|
50
|
+
o.separator "Options:"
|
|
51
|
+
o.on("--coverage PATH", "Local coverage file (SimpleCov .resultset.json or a Coverage.result dump)") { |v| options[:coverage] = v }
|
|
52
|
+
o.on("--coverage-format FORMAT", VALID_FORMATS, "Coverage format: auto (default), simplecov, or coverage") { |v| options[:coverage_format] = v }
|
|
53
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
54
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
55
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
56
|
+
end
|
|
57
|
+
@parser.permute!(argv)
|
|
58
|
+
options[:path] = argv.shift || "."
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def puts_help(_options)
|
|
63
|
+
puts @parser
|
|
64
|
+
0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def analyze(root, options)
|
|
68
|
+
root_dir = File.directory?(root) ? root : File.dirname(root)
|
|
69
|
+
files = File.directory?(root) ? Discovery.ruby_files(root) : [root]
|
|
70
|
+
|
|
71
|
+
index = Index.build(root: root_dir, paths: files)
|
|
72
|
+
dataset = Coverage.load(options[:coverage], root: root_dir, format: options[:coverage_format])
|
|
73
|
+
note(options, "loaded #{dataset.entries.size} covered files (#{dataset.source.backend}); #{dataset.unmatched_count} outside root ignored.")
|
|
74
|
+
|
|
75
|
+
CoverageReport.build(
|
|
76
|
+
index: index,
|
|
77
|
+
coverage: dataset,
|
|
78
|
+
root: root_dir,
|
|
79
|
+
git_ref: Git.head_ref(root_dir),
|
|
80
|
+
generated_at: Time.now.utc.iso8601,
|
|
81
|
+
backend_version: rubydex_version
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render(report, options)
|
|
86
|
+
case options[:format]
|
|
87
|
+
when :json then Formatters::CoverageJson.render(report)
|
|
88
|
+
else Formatters::CoverageTable.render(report)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def rubydex_version
|
|
93
|
+
defined?(Rubydex::VERSION) ? Rubydex::VERSION : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def note(options, message)
|
|
97
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult deadcode [PATH]` — list confidence-graded dead-code candidates.
|
|
9
|
+
# Thin layer: parse options, build the index + Rails awareness, drive the
|
|
10
|
+
# library, hand the {DeadCodeReport} to a formatter. Report-only: exit 0 on
|
|
11
|
+
# success, non-zero only on error.
|
|
12
|
+
class DeadCodeCommand
|
|
13
|
+
DEFAULT_MIN_CONFIDENCE = 0.0
|
|
14
|
+
|
|
15
|
+
# @return [Integer] process exit status
|
|
16
|
+
def run(argv)
|
|
17
|
+
options = parse(argv)
|
|
18
|
+
return puts_help(options) if options[:help]
|
|
19
|
+
|
|
20
|
+
root = File.expand_path(options[:path])
|
|
21
|
+
unless File.exist?(root)
|
|
22
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
23
|
+
return 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
report = analyze(root, options)
|
|
27
|
+
puts render(report, options)
|
|
28
|
+
0
|
|
29
|
+
rescue OptionParser::ParseError => e
|
|
30
|
+
warn "moult: #{e.message}"
|
|
31
|
+
1
|
|
32
|
+
rescue => e
|
|
33
|
+
warn "moult: #{e.message}"
|
|
34
|
+
1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
VALID_COVERAGE_FORMATS = %i[auto simplecov coverage].freeze
|
|
40
|
+
|
|
41
|
+
def parse(argv)
|
|
42
|
+
options = {format: :table, min_confidence: DEFAULT_MIN_CONFIDENCE, rails: true, quiet: false,
|
|
43
|
+
coverage: nil, coverage_format: :auto}
|
|
44
|
+
@parser = OptionParser.new do |o|
|
|
45
|
+
o.banner = "Usage: moult deadcode [PATH] [options]"
|
|
46
|
+
o.separator ""
|
|
47
|
+
o.separator "Options:"
|
|
48
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
49
|
+
o.on("--min-confidence N", Float, "Hide findings below this confidence 0..1 (default #{DEFAULT_MIN_CONFIDENCE})") { |v| options[:min_confidence] = v }
|
|
50
|
+
o.on("--[no-]rails", "Apply Rails entrypoint awareness (default on)") { |v| options[:rails] = v }
|
|
51
|
+
o.on("--coverage PATH", "Merge a local coverage file as runtime evidence (SimpleCov .resultset.json or a Coverage.result dump)") { |v| options[:coverage] = v }
|
|
52
|
+
o.on("--coverage-format FORMAT", VALID_COVERAGE_FORMATS, "Coverage format: auto (default), simplecov, or coverage") { |v| options[:coverage_format] = v }
|
|
53
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
54
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
55
|
+
end
|
|
56
|
+
@parser.permute!(argv)
|
|
57
|
+
options[:path] = argv.shift || "."
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def puts_help(_options)
|
|
62
|
+
puts @parser
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def analyze(root, options)
|
|
67
|
+
root_dir, files = Support.discover(root)
|
|
68
|
+
index = Index.build(root: root_dir, paths: files)
|
|
69
|
+
rails = Support.build_rails(root_dir, files, enabled: options[:rails])
|
|
70
|
+
coverage = load_coverage(root_dir, options)
|
|
71
|
+
note(options, "analysed #{files.size} files; index #{index.resolved? ? "resolved" : "unresolved"}.")
|
|
72
|
+
unless rails.rails?
|
|
73
|
+
note(options, "not a Rails app (or --no-rails); framework entrypoint awareness is off.")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
DeadCode.build_report(
|
|
77
|
+
root: root_dir,
|
|
78
|
+
files: files,
|
|
79
|
+
index: index,
|
|
80
|
+
rails: rails,
|
|
81
|
+
min_confidence: options[:min_confidence],
|
|
82
|
+
git_ref: Git.head_ref(root_dir),
|
|
83
|
+
generated_at: Time.now.utc.iso8601,
|
|
84
|
+
backend_version: rubydex_version,
|
|
85
|
+
coverage: coverage
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_coverage(root_dir, options)
|
|
90
|
+
return nil unless options[:coverage]
|
|
91
|
+
dataset = Coverage.load(options[:coverage], root: root_dir, format: options[:coverage_format])
|
|
92
|
+
note(options, "merged #{dataset.entries.size} covered files (#{dataset.source.backend}); #{dataset.unmatched_count} outside root ignored.")
|
|
93
|
+
dataset
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render(report, options)
|
|
97
|
+
case options[:format]
|
|
98
|
+
when :json then Formatters::DeadCodeJson.render(report)
|
|
99
|
+
else Formatters::DeadCodeTable.render(report)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def rubydex_version
|
|
104
|
+
defined?(Rubydex::VERSION) ? Rubydex::VERSION : nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def note(options, message)
|
|
108
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult duplication [PATH]` — list confidence-graded structural-clone groups.
|
|
9
|
+
# Thin layer: parse options, discover files, drive the library, hand the
|
|
10
|
+
# {DuplicationReport} to a formatter. Report-only: exit 0 on success, non-zero
|
|
11
|
+
# only on error.
|
|
12
|
+
class DuplicationCommand
|
|
13
|
+
DEFAULT_MIN_CONFIDENCE = 0.0
|
|
14
|
+
|
|
15
|
+
# @return [Integer] process exit status
|
|
16
|
+
def run(argv)
|
|
17
|
+
options = parse(argv)
|
|
18
|
+
return puts_help(options) if options[:help]
|
|
19
|
+
|
|
20
|
+
root = File.expand_path(options[:path])
|
|
21
|
+
unless File.exist?(root)
|
|
22
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
23
|
+
return 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
report = analyze(root, options)
|
|
27
|
+
puts render(report, options)
|
|
28
|
+
0
|
|
29
|
+
rescue OptionParser::ParseError => e
|
|
30
|
+
warn "moult: #{e.message}"
|
|
31
|
+
1
|
|
32
|
+
rescue => e
|
|
33
|
+
warn "moult: #{e.message}"
|
|
34
|
+
1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse(argv)
|
|
40
|
+
options = {format: :table, min_mass: Clones::DEFAULT_MIN_MASS, fuzzy: false,
|
|
41
|
+
min_confidence: DEFAULT_MIN_CONFIDENCE, quiet: false}
|
|
42
|
+
@parser = OptionParser.new do |o|
|
|
43
|
+
o.banner = "Usage: moult duplication [PATH] [options]"
|
|
44
|
+
o.separator ""
|
|
45
|
+
o.separator "Options:"
|
|
46
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
47
|
+
o.on("--min-mass N", Integer, "Ignore clones below this structural mass (default #{Clones::DEFAULT_MIN_MASS})") { |v| options[:min_mass] = v }
|
|
48
|
+
o.on("--[no-]fuzzy", "Also report near-matches, not just structural-equivalents (default off)") { |v| options[:fuzzy] = v }
|
|
49
|
+
o.on("--min-confidence N", Float, "Hide findings below this confidence 0..1 (default #{DEFAULT_MIN_CONFIDENCE})") { |v| options[:min_confidence] = v }
|
|
50
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
51
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
52
|
+
end
|
|
53
|
+
@parser.permute!(argv)
|
|
54
|
+
options[:path] = argv.shift || "."
|
|
55
|
+
options
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def puts_help(_options)
|
|
59
|
+
puts @parser
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def analyze(root, options)
|
|
64
|
+
root_dir = File.directory?(root) ? root : File.dirname(root)
|
|
65
|
+
files = File.directory?(root) ? Discovery.ruby_files(root) : [root]
|
|
66
|
+
mode = options[:fuzzy] ? ", fuzzy" : ""
|
|
67
|
+
note(options, "scanned #{files.size} files for duplication (flay, min-mass #{options[:min_mass]}#{mode}).")
|
|
68
|
+
|
|
69
|
+
Duplication.build_report(
|
|
70
|
+
root: root_dir,
|
|
71
|
+
files: files,
|
|
72
|
+
min_mass: options[:min_mass],
|
|
73
|
+
fuzzy: options[:fuzzy],
|
|
74
|
+
min_confidence: options[:min_confidence],
|
|
75
|
+
git_ref: Git.head_ref(root_dir),
|
|
76
|
+
generated_at: Time.now.utc.iso8601
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render(report, options)
|
|
81
|
+
case options[:format]
|
|
82
|
+
when :json then Formatters::DuplicationJson.render(report)
|
|
83
|
+
else Formatters::DuplicationTable.render(report)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def note(options, message)
|
|
88
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult flags [PATH]` — catalogue OpenFeature feature-flag references found by a
|
|
9
|
+
# static Prism scan. Thin layer: parse options, discover files, drive the
|
|
10
|
+
# library, hand the {FlagsReport} to a formatter. Report-only: exit 0 on success
|
|
11
|
+
# (including when no flags are found), non-zero only on error.
|
|
12
|
+
class FlagsCommand
|
|
13
|
+
# @return [Integer] process exit status
|
|
14
|
+
def run(argv)
|
|
15
|
+
options = parse(argv)
|
|
16
|
+
return puts_help(options) if options[:help]
|
|
17
|
+
|
|
18
|
+
root = File.expand_path(options[:path])
|
|
19
|
+
unless File.exist?(root)
|
|
20
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
21
|
+
return 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
report = analyze(root, options)
|
|
25
|
+
puts render(report, options)
|
|
26
|
+
0
|
|
27
|
+
rescue OptionParser::ParseError => e
|
|
28
|
+
warn "moult: #{e.message}"
|
|
29
|
+
1
|
|
30
|
+
rescue => e
|
|
31
|
+
warn "moult: #{e.message}"
|
|
32
|
+
1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
VALID_PROVIDER_FORMATS = %i[auto flagd].freeze
|
|
38
|
+
|
|
39
|
+
def parse(argv)
|
|
40
|
+
options = {format: :table, quiet: false, provider: nil, provider_format: :auto}
|
|
41
|
+
@parser = OptionParser.new do |o|
|
|
42
|
+
o.banner = "Usage: moult flags [PATH] [options]"
|
|
43
|
+
o.separator ""
|
|
44
|
+
o.separator "Options:"
|
|
45
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
46
|
+
o.on("--provider PATH", "Merge a local OpenFeature provider snapshot (a flagd flag-definition export) for confidence-graded staleness candidates") { |v| options[:provider] = v }
|
|
47
|
+
o.on("--provider-format FORMAT", VALID_PROVIDER_FORMATS, "Provider snapshot format: auto (default) or flagd") { |v| options[:provider_format] = v }
|
|
48
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
49
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
50
|
+
end
|
|
51
|
+
@parser.permute!(argv)
|
|
52
|
+
options[:path] = argv.shift || "."
|
|
53
|
+
options
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def puts_help(_options)
|
|
57
|
+
puts @parser
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def analyze(root, options)
|
|
62
|
+
root_dir = File.directory?(root) ? root : File.dirname(root)
|
|
63
|
+
files = File.directory?(root) ? Discovery.ruby_files(root) : [root]
|
|
64
|
+
snapshot = load_snapshot(options)
|
|
65
|
+
report = Flags.build_report(
|
|
66
|
+
root: root_dir,
|
|
67
|
+
files: files,
|
|
68
|
+
git_ref: Git.head_ref(root_dir),
|
|
69
|
+
generated_at: Time.now.utc.iso8601,
|
|
70
|
+
snapshot: snapshot
|
|
71
|
+
)
|
|
72
|
+
note(options, "scanned #{files.size} files for OpenFeature flag references: #{report.summary[:flags]} flags.")
|
|
73
|
+
report
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def load_snapshot(options)
|
|
77
|
+
return nil unless options[:provider]
|
|
78
|
+
set = Flags::Snapshot.load(options[:provider], format: options[:provider_format])
|
|
79
|
+
note(options, "merged provider snapshot (#{set.source.backend}): #{set.states.size} flags known to the provider.")
|
|
80
|
+
set
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render(report, options)
|
|
84
|
+
case options[:format]
|
|
85
|
+
when :json then Formatters::FlagsJson.render(report)
|
|
86
|
+
else Formatters::FlagsTable.render(report)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def note(options, message)
|
|
91
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult gate [PATH]` — the diff-aware PR risk gate. Thin layer: parse options,
|
|
9
|
+
# build the index + Rails awareness, resolve the policy (defaults, overridable
|
|
10
|
+
# via .moult.yml), drive {Gate.build_report}, hand the {GateReport} to a
|
|
11
|
+
# formatter. Holds NO policy logic.
|
|
12
|
+
#
|
|
13
|
+
# Exit code (the one command in Moult that renders a verdict, so it is the one
|
|
14
|
+
# exception to the repo-wide "1 = error" convention):
|
|
15
|
+
# 0 = gate passed
|
|
16
|
+
# 1 = gate failed (policy violated)
|
|
17
|
+
# 2 = tool error (bad option, missing path, unresolvable diff, …)
|
|
18
|
+
class GateCommand
|
|
19
|
+
PASS = 0
|
|
20
|
+
FAIL = 1
|
|
21
|
+
ERROR = 2
|
|
22
|
+
|
|
23
|
+
# @return [Integer] process exit status
|
|
24
|
+
def run(argv)
|
|
25
|
+
options = parse(argv)
|
|
26
|
+
return puts_help if options[:help]
|
|
27
|
+
|
|
28
|
+
execute(options)
|
|
29
|
+
rescue OptionParser::ParseError, Moult::Error => e
|
|
30
|
+
warn "moult: #{e.message}"
|
|
31
|
+
ERROR
|
|
32
|
+
rescue => e
|
|
33
|
+
warn "moult: #{e.message}"
|
|
34
|
+
ERROR
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def execute(options)
|
|
40
|
+
root = File.expand_path(options[:path])
|
|
41
|
+
unless File.exist?(root)
|
|
42
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
43
|
+
return ERROR
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
report = analyze(root, options)
|
|
47
|
+
puts render(report, options)
|
|
48
|
+
(report.verdict == "pass") ? PASS : FAIL
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse(argv)
|
|
52
|
+
options = {format: :table, base: "origin/main", scope: :diff,
|
|
53
|
+
config: nil, rails: true, quiet: false}
|
|
54
|
+
@parser = OptionParser.new do |o|
|
|
55
|
+
o.banner = "Usage: moult gate [PATH] [options]"
|
|
56
|
+
o.separator ""
|
|
57
|
+
o.separator "Diff-aware PR risk gate: scopes the analyses to the code changed since a"
|
|
58
|
+
o.separator "base ref, applies an explicit policy, and exits non-zero when violated."
|
|
59
|
+
o.separator ""
|
|
60
|
+
o.separator "Options:"
|
|
61
|
+
o.on("--base REF", "Base ref for the diff (default 'origin/main'); gate uses merge-base(REF, HEAD)") { |v| options[:base] = v }
|
|
62
|
+
o.on("--scope SCOPE", [:diff, :all], "What to gate: diff (default, new code only) or all (whole codebase)") { |v| options[:scope] = v }
|
|
63
|
+
o.on("--format FORMAT", [:table, :json, :github, :sarif], "Output: table (default), json, github (annotations), or sarif") { |v| options[:format] = v }
|
|
64
|
+
o.on("--config FILE", "Policy overrides file (default: .moult.yml at the root, if present)") { |v| options[:config] = v }
|
|
65
|
+
o.on("--[no-]rails", "Apply Rails entrypoint awareness to dead code (default on)") { |v| options[:rails] = v }
|
|
66
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
67
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
68
|
+
end
|
|
69
|
+
@parser.permute!(argv)
|
|
70
|
+
options[:path] = argv.shift || "."
|
|
71
|
+
options
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def puts_help
|
|
75
|
+
puts @parser
|
|
76
|
+
PASS
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def analyze(root, options)
|
|
80
|
+
root_dir, files = Support.discover(root)
|
|
81
|
+
index = Index.build(root: root_dir, paths: files)
|
|
82
|
+
rails = Support.build_rails(root_dir, files, enabled: options[:rails])
|
|
83
|
+
policy = Gate::Config.policy_for(root: root_dir, config_path: options[:config])
|
|
84
|
+
note(options, "gating #{files.size} files (scope: #{options[:scope]}, policy: #{policy.source}); index #{index.resolved? ? "resolved" : "unresolved"}.")
|
|
85
|
+
|
|
86
|
+
Gate.build_report(
|
|
87
|
+
root: root_dir,
|
|
88
|
+
files: files,
|
|
89
|
+
index: index,
|
|
90
|
+
rails: rails,
|
|
91
|
+
base_ref: options[:base],
|
|
92
|
+
scope: options[:scope],
|
|
93
|
+
policy: policy,
|
|
94
|
+
git_ref: Git.head_ref(root_dir),
|
|
95
|
+
generated_at: Time.now.utc.iso8601
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render(report, options)
|
|
100
|
+
case options[:format]
|
|
101
|
+
when :json then Formatters::GateJson.render(report)
|
|
102
|
+
when :github then Formatters::GateGithub.render(report)
|
|
103
|
+
when :sarif then Formatters::GateSarif.render(report)
|
|
104
|
+
else Formatters::GateTable.render(report)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def note(options, message)
|
|
109
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moult
|
|
7
|
+
class CLI
|
|
8
|
+
# `moult health [PATH]` — a composite, auditable health score aggregated from
|
|
9
|
+
# the other analyses. Thin layer: parse options, build the index + Rails
|
|
10
|
+
# awareness (+ optional coverage), drive {Health.build_report}, hand the
|
|
11
|
+
# {HealthReport} to a formatter. Report-only: exit 0 on success (even on a low
|
|
12
|
+
# score — the PR gate is Phase 4), non-zero only on a hard error.
|
|
13
|
+
class HealthCommand
|
|
14
|
+
VALID_COVERAGE_FORMATS = %i[auto simplecov coverage].freeze
|
|
15
|
+
|
|
16
|
+
# @return [Integer] process exit status
|
|
17
|
+
def run(argv)
|
|
18
|
+
options = parse(argv)
|
|
19
|
+
return puts_help(options) if options[:help]
|
|
20
|
+
|
|
21
|
+
root = File.expand_path(options[:path])
|
|
22
|
+
unless File.exist?(root)
|
|
23
|
+
warn "moult: no such file or directory: #{options[:path]}"
|
|
24
|
+
return 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
report = analyze(root, options)
|
|
28
|
+
puts render(report, options)
|
|
29
|
+
0
|
|
30
|
+
rescue OptionParser::ParseError => e
|
|
31
|
+
warn "moult: #{e.message}"
|
|
32
|
+
1
|
|
33
|
+
rescue => e
|
|
34
|
+
warn "moult: #{e.message}"
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse(argv)
|
|
41
|
+
options = {format: :table, rails: true, quiet: false,
|
|
42
|
+
coverage: nil, coverage_format: :auto, since: Churn::DEFAULT_SINCE}
|
|
43
|
+
@parser = OptionParser.new do |o|
|
|
44
|
+
o.banner = "Usage: moult health [PATH] [options]"
|
|
45
|
+
o.separator ""
|
|
46
|
+
o.separator "Aggregates complexity, dead code, duplication and (optionally) runtime"
|
|
47
|
+
o.separator "coverage into one confidence-graded health score. Report-only."
|
|
48
|
+
o.separator ""
|
|
49
|
+
o.separator "Options:"
|
|
50
|
+
o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
|
|
51
|
+
o.on("--coverage PATH", "Merge a local coverage file (SimpleCov .resultset.json or a Coverage.result dump)") { |v| options[:coverage] = v }
|
|
52
|
+
o.on("--coverage-format FORMAT", VALID_COVERAGE_FORMATS, "Coverage format: auto (default), simplecov, or coverage") { |v| options[:coverage_format] = v }
|
|
53
|
+
o.on("--[no-]rails", "Apply Rails entrypoint awareness to dead code (default on)") { |v| options[:rails] = v }
|
|
54
|
+
o.on("--since DATE", "Churn window start for complexity, any git --since value (default '#{Churn::DEFAULT_SINCE}')") { |v| options[:since] = v }
|
|
55
|
+
o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
|
|
56
|
+
o.on("-h", "--help", "Show this message") { options[:help] = true }
|
|
57
|
+
end
|
|
58
|
+
@parser.permute!(argv)
|
|
59
|
+
options[:path] = argv.shift || "."
|
|
60
|
+
options
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def puts_help(_options)
|
|
64
|
+
puts @parser
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def analyze(root, options)
|
|
69
|
+
root_dir, files = Support.discover(root)
|
|
70
|
+
index = Index.build(root: root_dir, paths: files)
|
|
71
|
+
rails = Support.build_rails(root_dir, files, enabled: options[:rails])
|
|
72
|
+
coverage = load_coverage(root_dir, options)
|
|
73
|
+
merged = coverage ? ", coverage merged" : ""
|
|
74
|
+
note(options, "scored #{files.size} files; index #{index.resolved? ? "resolved" : "unresolved"}#{merged}.")
|
|
75
|
+
|
|
76
|
+
Health.build_report(
|
|
77
|
+
root: root_dir,
|
|
78
|
+
files: files,
|
|
79
|
+
index: index,
|
|
80
|
+
rails: rails,
|
|
81
|
+
coverage: coverage,
|
|
82
|
+
since: options[:since],
|
|
83
|
+
git_ref: Git.head_ref(root_dir),
|
|
84
|
+
generated_at: Time.now.utc.iso8601,
|
|
85
|
+
churn_window: window_label(options[:since]),
|
|
86
|
+
churn_since: explicit_since(options[:since])
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def load_coverage(root_dir, options)
|
|
91
|
+
return nil unless options[:coverage]
|
|
92
|
+
dataset = Coverage.load(options[:coverage], root: root_dir, format: options[:coverage_format])
|
|
93
|
+
note(options, "merged #{dataset.entries.size} covered files (#{dataset.source.backend}); #{dataset.unmatched_count} outside root ignored.")
|
|
94
|
+
dataset
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render(report, options)
|
|
98
|
+
case options[:format]
|
|
99
|
+
when :json then Formatters::HealthJson.render(report)
|
|
100
|
+
else Formatters::HealthTable.render(report)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def window_label(since)
|
|
105
|
+
(since == Churn::DEFAULT_SINCE) ? "last 12 months" : "since #{since}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def explicit_since(since)
|
|
109
|
+
(since == Churn::DEFAULT_SINCE) ? nil : since
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def note(options, message)
|
|
113
|
+
warn "moult: #{message}" unless options[:quiet]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|