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