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,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