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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable table of duplication candidates. Renders from the same
6
+ # {DuplicationReport} as the JSON formatter so the two cannot disagree.
7
+ # Sorting already happened in {Duplication}; this layer owns column
8
+ # formatting only.
9
+ #
10
+ # The heading is deliberate: these are confidence-graded candidates, never
11
+ # certainties.
12
+ module DuplicationTable
13
+ MAX_LOCATIONS = 3
14
+ RIGHT_ALIGNED = [0, 2, 4].freeze # CONF, MASS, COUNT
15
+
16
+ module_function
17
+
18
+ # @param report [DuplicationReport]
19
+ # @return [String]
20
+ def render(report)
21
+ findings = report.findings
22
+ return "No duplication found." if findings.empty?
23
+
24
+ headers = %w[CONF KIND MASS NODE COUNT LOCATIONS]
25
+ rows = findings.map { |f| row(f) }
26
+ [heading(findings.size), "", TextTable.render(headers, rows, right_aligned: RIGHT_ALIGNED)].join("\n")
27
+ end
28
+
29
+ def heading(count)
30
+ "Duplication candidates (confidence-graded — not certainties): #{count} clone sets"
31
+ end
32
+
33
+ def row(finding)
34
+ [
35
+ conf(finding.confidence),
36
+ finding.kind.to_s,
37
+ finding.mass.to_s,
38
+ finding.node_type,
39
+ finding.occurrences.size.to_s,
40
+ locations(finding.occurrences)
41
+ ]
42
+ end
43
+
44
+ def locations(occurrences)
45
+ shown = occurrences.first(MAX_LOCATIONS).map { |o| "#{o.path}:#{o.line}" }
46
+ extra = occurrences.size - shown.size
47
+ extra.positive? ? "#{shown.join(", ")} (+#{extra} more)" : shown.join(", ")
48
+ end
49
+
50
+ def conf(value)
51
+ format("%.2f", value)
52
+ end
53
+ end
54
+ end
55
+ 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 {FlagsReport}. 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 FlagsJson
11
+ module_function
12
+
13
+ # @param report [FlagsReport]
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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable table of OpenFeature flag references. Renders from the same
6
+ # {FlagsReport} as the JSON formatter so the two cannot disagree. Sorting already
7
+ # happened in {Flags}; this layer owns column formatting only.
8
+ #
9
+ # The heading is deliberate. Without a provider snapshot these are flag *usage*
10
+ # facts (staleness needs a provider). With one, they are confidence-graded
11
+ # staleness *candidates* — never certainties; the STATUS/CONF columns and the
12
+ # heading say so.
13
+ module FlagsTable
14
+ MAX_LOCATIONS = 3
15
+ NO_DEFAULTS = "-"
16
+ NO_STALENESS = "-"
17
+ RIGHT_ALIGNED = [2].freeze # REFS (usage view)
18
+ RIGHT_ALIGNED_GRADED = [3, 4].freeze # CONF, REFS (staleness view)
19
+
20
+ module_function
21
+
22
+ # @param report [FlagsReport]
23
+ # @return [String]
24
+ def render(report)
25
+ findings = report.findings
26
+ return "No OpenFeature flag references found." if findings.empty?
27
+
28
+ graded = !report.provider_source.nil?
29
+ headers = graded ? %w[KEY TYPE STATUS CONF REFS DEFAULTS LOCATIONS] : %w[KEY TYPE REFS DEFAULTS LOCATIONS]
30
+ right = graded ? RIGHT_ALIGNED_GRADED : RIGHT_ALIGNED
31
+ rows = findings.map { |f| row(f, graded) }
32
+ [heading(report.summary, graded), "", TextTable.render(headers, rows, right_aligned: right)].join("\n")
33
+ end
34
+
35
+ def heading(summary, graded)
36
+ dynamic = summary[:dynamic_references]
37
+ tail = dynamic.positive? ? ", #{dynamic} dynamic (uncatalogued)" : ""
38
+ lead = if graded
39
+ "OpenFeature flag staleness candidates (confidence-graded, never certain): "
40
+ else
41
+ "OpenFeature flag references (usage facts, not staleness — that needs a live provider): "
42
+ end
43
+ "#{lead}#{summary[:flags]} flags, #{summary[:references]} references#{tail}"
44
+ end
45
+
46
+ def row(finding, graded)
47
+ cells = [finding.flag_key, finding.value_type]
48
+ cells.push(status(finding), confidence(finding)) if graded
49
+ cells.push(
50
+ finding.reference_count.to_s,
51
+ defaults(finding.default_values),
52
+ locations(finding.occurrences)
53
+ )
54
+ cells
55
+ end
56
+
57
+ def status(finding)
58
+ finding.staleness&.status || NO_STALENESS
59
+ end
60
+
61
+ def confidence(finding)
62
+ finding.staleness ? format("%.2f", finding.staleness.confidence) : NO_STALENESS
63
+ end
64
+
65
+ def defaults(values)
66
+ values.empty? ? NO_DEFAULTS : values.join(", ")
67
+ end
68
+
69
+ def locations(occurrences)
70
+ shown = occurrences.first(MAX_LOCATIONS).map { |o| "#{o.path}:#{o.line}" }
71
+ extra = occurrences.size - shown.size
72
+ extra.positive? ? "#{shown.join(", ")} (+#{extra} more)" : shown.join(", ")
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # CI projections of the gate verdict. These render the SAME contributing
5
+ # findings the JSON contract carries into machine formats a code-review tool can
6
+ # consume — but they only EMIT text (annotations / a SARIF document). Posting to
7
+ # any GitHub API is the App's job (Phase 4), explicitly out of scope here.
8
+ module Formatters
9
+ # GitHub Actions workflow-command annotations: one `::error` line per
10
+ # contributing finding, so a PR shows the gate's findings inline when run in
11
+ # Actions. Format and escaping follow the GitHub Actions workflow-commands
12
+ # spec. A passing gate emits a single `::notice`.
13
+ module GateGithub
14
+ module_function
15
+
16
+ # @param report [GateReport]
17
+ # @return [String]
18
+ def render(report)
19
+ failed = report.rules.select { |r| r.evaluated && r.passed == false }
20
+ return pass_notice(report) if failed.empty?
21
+
22
+ failed.flat_map { |rule| rule.findings.map { |f| annotation(rule, f) } }.join("\n")
23
+ end
24
+
25
+ def pass_notice(report)
26
+ "::notice title=#{escape_prop("Moult gate")}::#{escape_data("gate passed (#{report.summary[:evaluated]} rules evaluated)")}"
27
+ end
28
+
29
+ def annotation(rule, finding)
30
+ props = {file: finding.path}
31
+ props[:line] = finding.line if finding.line
32
+ props[:title] = "Moult gate: #{rule.rule}"
33
+ prop_str = props.map { |k, v| "#{k}=#{escape_prop(v.to_s)}" }.join(",")
34
+ "::error #{prop_str}::#{escape_data(message(rule, finding))}"
35
+ end
36
+
37
+ def message(rule, finding)
38
+ GateMessage.for(rule, finding)
39
+ end
40
+
41
+ # Per the workflow-command spec: escape % CR LF in message data; additionally
42
+ # escape : and , in property values.
43
+ def escape_data(value)
44
+ value.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
45
+ end
46
+
47
+ def escape_prop(value)
48
+ escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # The gate's machine contract: a thin pass-through over {GateReport#to_h},
8
+ # validated against schema/gate.schema.json. Renders from the same report as
9
+ # every other gate formatter so they cannot drift.
10
+ module GateJson
11
+ module_function
12
+
13
+ # @param report [GateReport]
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,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # The one-line description of a gate finding, shared by the GitHub-annotation
6
+ # and SARIF projections so the two render identical text. Stays humble: it
7
+ # reports the observed signal against the threshold, never a claim of certainty.
8
+ module GateMessage
9
+ module_function
10
+
11
+ # @param rule [Gate::Evaluation::RuleOutcome]
12
+ # @param finding [Gate::Evaluation::Contribution]
13
+ # @return [String]
14
+ def for(rule, finding)
15
+ "#{finding.category} #{finding.value} on changed code violates #{rule.rule} (threshold #{rule.threshold})"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # SARIF 2.1.0 projection of the gate verdict — the static-analysis interchange
8
+ # format GitHub code scanning and reviewdog consume. One `rule` per policy
9
+ # rule; one `result` (level "error") per contributing finding behind a failed
10
+ # rule. Emits the document only; uploading it is the consumer's job.
11
+ #
12
+ # A finding's `value` is a graded/classified signal (confidence/ABC/mass/
13
+ # severity), so the result text reports it as such — never as a certainty.
14
+ module GateSarif
15
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
16
+ INFORMATION_URI = "https://github.com/moult-rb/moult-rb"
17
+
18
+ module_function
19
+
20
+ # @param report [GateReport]
21
+ # @return [String]
22
+ def render(report)
23
+ JSON.pretty_generate(document(report))
24
+ end
25
+
26
+ def document(report)
27
+ {
28
+ "$schema" => SARIF_SCHEMA,
29
+ "version" => "2.1.0",
30
+ "runs" => [{
31
+ "tool" => {
32
+ "driver" => {
33
+ "name" => "moult",
34
+ "version" => Moult::VERSION,
35
+ "informationUri" => INFORMATION_URI,
36
+ "rules" => report.rules.map { |r| rule_descriptor(r) }
37
+ }
38
+ },
39
+ "results" => results(report)
40
+ }]
41
+ }
42
+ end
43
+
44
+ def rule_descriptor(rule)
45
+ {
46
+ "id" => rule.rule,
47
+ "shortDescription" => {"text" => rule.rule.tr("_", " ")},
48
+ "properties" => {"threshold" => rule.threshold.to_s, "evaluated" => rule.evaluated}
49
+ }
50
+ end
51
+
52
+ def results(report)
53
+ report.rules.select { |r| r.evaluated && r.passed == false }.flat_map do |rule|
54
+ rule.findings.map { |f| result(rule, f) }
55
+ end
56
+ end
57
+
58
+ def result(rule, finding)
59
+ {
60
+ "ruleId" => rule.rule,
61
+ "level" => "error",
62
+ "message" => {"text" => message(rule, finding)},
63
+ "locations" => [{"physicalLocation" => physical_location(finding)}]
64
+ }
65
+ end
66
+
67
+ def physical_location(finding)
68
+ location = {"artifactLocation" => {"uri" => finding.path}}
69
+ location["region"] = {"startLine" => finding.line} if finding.line
70
+ location
71
+ end
72
+
73
+ def message(rule, finding)
74
+ GateMessage.for(rule, finding)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable gate result: a PASS/FAIL banner, the scope it ran over, a
6
+ # per-rule table (with each rule's observed value vs threshold), and the
7
+ # contributing findings behind any failure. Renders from the same {GateReport}
8
+ # as the JSON/CI formatters so they cannot disagree.
9
+ #
10
+ # The verdict is an auditable application of the recorded policy — the heading
11
+ # says so; nothing here claims the code is certainly wrong.
12
+ module GateTable
13
+ module_function
14
+
15
+ # @param report [GateReport]
16
+ # @return [String]
17
+ def render(report)
18
+ [banner(report), "", rule_table(report), contributions(report)]
19
+ .reject(&:empty?).join("\n")
20
+ end
21
+
22
+ def banner(report)
23
+ verdict = report.verdict.upcase
24
+ "moult gate: #{verdict} (#{scope_label(report)})"
25
+ end
26
+
27
+ def scope_label(report)
28
+ if report.scope == :all || report.scope == "all"
29
+ "scope: all (whole codebase)"
30
+ else
31
+ base = report.base_ref || "base"
32
+ mb = report.merge_base ? " @ #{report.merge_base[0, 7]}" : ""
33
+ "scope: diff vs #{base}#{mb}"
34
+ end
35
+ end
36
+
37
+ def rule_table(report)
38
+ headers = %w[RULE OBSERVED THRESHOLD RESULT]
39
+ rows = report.rules.map do |rule|
40
+ [rule.rule, observed(rule), rule.threshold.to_s, result(rule)]
41
+ end
42
+ TextTable.render(headers, rows)
43
+ end
44
+
45
+ def observed(rule)
46
+ return "-" if rule.observed.nil?
47
+
48
+ rule.observed.to_s
49
+ end
50
+
51
+ def result(rule)
52
+ return "skipped" unless rule.evaluated
53
+
54
+ rule.passed ? "pass" : "FAIL"
55
+ end
56
+
57
+ def contributions(report)
58
+ failed = report.rules.select { |r| r.evaluated && r.passed == false }
59
+ return "" if failed.empty?
60
+
61
+ lines = failed.flat_map { |rule| rule.findings.map { |f| contribution_line(rule, f) } }
62
+ ["", "Contributing findings:", *lines].join("\n")
63
+ end
64
+
65
+ def contribution_line(rule, finding)
66
+ loc = finding.line ? "#{finding.path}:#{finding.line}" : finding.path
67
+ " [#{rule.rule}] #{loc} #{finding.category} (#{finding.value})"
68
+ end
69
+ end
70
+ end
71
+ 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 health JSON contract (schema/health.schema.json). Renders
8
+ # straight from {HealthReport#to_h} so the serialized shape cannot drift from
9
+ # the result model.
10
+ module HealthJson
11
+ module_function
12
+
13
+ # @param report [HealthReport]
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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable health summary: a headline composite, the per-component
6
+ # breakdown (including the skipped/errored ones), and the least-healthy files.
7
+ # Renders from the same {HealthReport} as the JSON formatter so the two cannot
8
+ # disagree; ordering already happened in {Health}.
9
+ #
10
+ # The heading is deliberate: this is a graded signal, never a verdict.
11
+ module HealthTable
12
+ DEFAULT_FILE_LIMIT = 20
13
+ DASH = "—"
14
+
15
+ module_function
16
+
17
+ # @param report [HealthReport]
18
+ # @param file_limit [Integer, nil] how many worst files to show (nil = all)
19
+ # @return [String]
20
+ def render(report, file_limit: DEFAULT_FILE_LIMIT)
21
+ [heading(report), "", components_section(report), "", files_section(report, file_limit)]
22
+ .join("\n").rstrip
23
+ end
24
+
25
+ def heading(report)
26
+ if report.score.nil?
27
+ "Codebase health: n/a — no analysis produced a signal"
28
+ else
29
+ present = report.components.count(&:present)
30
+ total = report.components.size
31
+ "Codebase health: #{report.grade} (#{format("%.2f", report.score)}) " \
32
+ "— a graded signal, not a verdict [#{present}/#{total} components]"
33
+ end
34
+ end
35
+
36
+ def components_section(report)
37
+ headers = %w[COMPONENT SCORE WEIGHT NOTE]
38
+ rows = report.components.map { |c| component_row(c) }
39
+ right = [1, 2] # SCORE, WEIGHT
40
+ ["Components:", TextTable.render(headers, rows, right_aligned: right)].join("\n")
41
+ end
42
+
43
+ def component_row(component)
44
+ [
45
+ component.name,
46
+ component.present ? format("%.2f", component.score) : DASH,
47
+ format("%.2f", component.weight),
48
+ component_note(component)
49
+ ]
50
+ end
51
+
52
+ def component_note(component)
53
+ return component.diagnostic.to_s unless component.present
54
+ component.reasons.first&.detail.to_s
55
+ end
56
+
57
+ def files_section(report, file_limit)
58
+ files = report.files
59
+ return "Files: none with a health signal." if files.empty?
60
+
61
+ shown = file_limit ? files.first(file_limit) : files
62
+ headers = %w[SCORE GRADE FILE COMPONENTS]
63
+ rows = shown.map { |f| file_row(f) }
64
+ extra = files.size - shown.size
65
+ suffix = extra.positive? ? " (top #{shown.size} of #{files.size})" : ""
66
+ title = "Least-healthy files#{suffix}:"
67
+ [title, TextTable.render(headers, rows, right_aligned: [0])].join("\n")
68
+ end
69
+
70
+ def file_row(file)
71
+ [
72
+ format("%.2f", file.score),
73
+ file.grade,
74
+ file.path,
75
+ file.components.keys.join(",")
76
+ ]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moult
6
+ module Formatters
7
+ # Emits the typed JSON output contract (schema/hotspots.schema.json). Renders
8
+ # straight from {Report#to_h}; only presentation concerns (limiting) live
9
+ # here, so the serialized shape can never drift from the result model.
10
+ module Json
11
+ module_function
12
+
13
+ # @param report [Report]
14
+ # @param limit [Integer, nil] keep only the top N hotspots
15
+ # @return [String] pretty-printed JSON
16
+ def render(report, limit: nil)
17
+ data = report.to_h
18
+ data[:hotspots] = data[:hotspots].first(limit) if limit
19
+ JSON.pretty_generate(data)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Human-readable ranked table. Renders from the same {Report} as the JSON
6
+ # formatter, so the two cannot disagree. Sorting already happened in
7
+ # {Scoring}; this layer owns limiting and column formatting only.
8
+ module Table
9
+ HEADERS = ["#", "SCORE", "COMPLEXITY", "CHURN", "FILE", "WORST METHOD"].freeze
10
+ # Right-align the numeric columns; left-align file and method.
11
+ RIGHT_ALIGNED = [0, 1, 2, 3].freeze
12
+ GUTTER = " "
13
+
14
+ module_function
15
+
16
+ # @param report [Report]
17
+ # @param limit [Integer, nil] show only the top N hotspots
18
+ # @return [String]
19
+ def render(report, limit: nil)
20
+ hotspots = report.hotspots
21
+ hotspots = hotspots.first(limit) if limit
22
+ return "No hotspots found." if hotspots.empty?
23
+
24
+ rows = hotspots.each_with_index.map { |h, i| row(h, i + 1) }
25
+ [heading(report, hotspots.size), "", table(rows)].join("\n")
26
+ end
27
+
28
+ def heading(report, shown)
29
+ total = report.hotspots.size
30
+ scope = (shown < total) ? "top #{shown} of #{total}" : total.to_s
31
+ window = report.churn_window ? " — churn over #{report.churn_window}" : ""
32
+ "Hotspots (complexity x churn): #{scope} files#{window}"
33
+ end
34
+
35
+ def row(hotspot, rank)
36
+ worst = hotspot.worst_method
37
+ worst_cell = worst ? "#{worst.name} (#{num(worst.abc)})" : "-"
38
+ [
39
+ rank.to_s,
40
+ num(hotspot.score),
41
+ num(hotspot.complexity),
42
+ hotspot.churn.to_s,
43
+ hotspot.path,
44
+ worst_cell
45
+ ]
46
+ end
47
+
48
+ def table(rows)
49
+ widths = column_widths(rows)
50
+ ([HEADERS] + rows).map { |cells| format_row(cells, widths) }.join("\n")
51
+ end
52
+
53
+ def column_widths(rows)
54
+ HEADERS.each_index.map do |col|
55
+ ([HEADERS[col]] + rows.map { |r| r[col] }).map(&:length).max
56
+ end
57
+ end
58
+
59
+ def format_row(cells, widths)
60
+ cells.each_with_index.map { |cell, col|
61
+ RIGHT_ALIGNED.include?(col) ? cell.rjust(widths[col]) : cell.ljust(widths[col])
62
+ }.join(GUTTER).rstrip
63
+ end
64
+
65
+ def num(value)
66
+ format("%.1f", value)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Formatters
5
+ # Shared plumbing for the header + rows text tables every analysis formatter
6
+ # renders. Extracted so the column-width/alignment logic lives in exactly one
7
+ # place instead of being copied into each `*_table` formatter (the gate caught
8
+ # that duplication when run on Moult itself).
9
+ #
10
+ # Columns left-align by default; pass the 0-based indices to right-align (e.g.
11
+ # numeric columns) in +right_aligned+.
12
+ module TextTable
13
+ GUTTER = " "
14
+
15
+ module_function
16
+
17
+ # @param headers [Array<String>]
18
+ # @param rows [Array<Array<String>>]
19
+ # @param right_aligned [Array<Integer>] 0-based column indices to right-align
20
+ # @return [String] the header row followed by each data row, newline-joined
21
+ def render(headers, rows, right_aligned: [])
22
+ widths = column_widths(headers, rows)
23
+ ([headers] + rows).map { |cells| format_row(cells, widths, right_aligned) }.join("\n")
24
+ end
25
+
26
+ def column_widths(headers, rows)
27
+ headers.each_index.map do |col|
28
+ ([headers[col]] + rows.map { |r| r[col] }).map(&:length).max
29
+ end
30
+ end
31
+
32
+ def format_row(cells, widths, right_aligned)
33
+ cells.each_with_index.map { |cell, col|
34
+ right_aligned.include?(col) ? cell.rjust(widths[col]) : cell.ljust(widths[col])
35
+ }.join(GUTTER).rstrip
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Moult
6
+ module Gate
7
+ # Loads gate policy overrides from a project config file (.moult.yml by
8
+ # default) and hands them to {Policy}. Config is plain YAML — psych is stdlib,
9
+ # so the gate adds no new runtime dependency. Only the `gate:` section is read;
10
+ # everything else is ignored, leaving room for future Moult config.
11
+ #
12
+ # IO lives here, never in the pure {Policy}/{Evaluation} models: this resolves
13
+ # a path and reads a file, then defers entirely to {Policy.load}.
14
+ module Config
15
+ DEFAULT_FILENAME = ".moult.yml"
16
+
17
+ module_function
18
+
19
+ # @param root [String] absolute analysis root
20
+ # @param config_path [String, nil] explicit --config path; nil auto-detects
21
+ # .moult.yml at the root
22
+ # @return [Policy] defaults when no config is present
23
+ # @raise [Moult::Error] when an explicit path is missing or the file is unreadable
24
+ def policy_for(root:, config_path: nil)
25
+ path = resolve(root, config_path)
26
+ return Policy.default unless path
27
+
28
+ data = YAML.safe_load_file(path) || {}
29
+ unless data.is_a?(Hash)
30
+ raise Moult::Error, "config #{relative(path, root)} must be a YAML mapping"
31
+ end
32
+
33
+ overrides = data["gate"] || data[:gate] || {}
34
+ Policy.load(overrides, source: relative(path, root))
35
+ rescue Psych::SyntaxError => e
36
+ raise Moult::Error, "could not parse config #{relative(path, root)}: #{e.message}"
37
+ end
38
+
39
+ def resolve(root, config_path)
40
+ if config_path
41
+ return config_path if File.file?(config_path)
42
+
43
+ raise Moult::Error, "no such config file: #{config_path}"
44
+ end
45
+
46
+ default = File.join(root, DEFAULT_FILENAME)
47
+ File.file?(default) ? default : nil
48
+ end
49
+
50
+ def relative(path, root)
51
+ SymbolId.relative_path(File.expand_path(path), root)
52
+ end
53
+ end
54
+ end
55
+ end