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,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# Aggregates the per-method ABC and per-file churn into a ranked {Report}.
|
|
7
|
+
#
|
|
8
|
+
# File complexity is the sum of its methods' ABC; the file score is
|
|
9
|
+
# complexity x churn. This raw product is dominated by outliers - acceptable
|
|
10
|
+
# for v0.1, but {combine} is isolated so a normalisation strategy (log, rank,
|
|
11
|
+
# z-score) can drop in later without touching the rest of the pipeline.
|
|
12
|
+
#
|
|
13
|
+
# Files with no methods (or only zero-scoring ones) are omitted: they cannot
|
|
14
|
+
# be a complexity hotspot. Ranking is score-descending, with complexity then
|
|
15
|
+
# path as deterministic tie-breakers (so 0-churn files - e.g. outside a repo -
|
|
16
|
+
# still order by complexity rather than arbitrarily).
|
|
17
|
+
module Scoring
|
|
18
|
+
DEFAULT_WORST_METHODS = 3
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param root [String] absolute analysis root
|
|
23
|
+
# @param files [Array<String>] absolute paths of Ruby files to analyse
|
|
24
|
+
# @param churn [Hash{String=>Integer}] path (relative to root) => commit count
|
|
25
|
+
# @param worst_methods [Integer] how many worst methods to keep per file
|
|
26
|
+
# @return [Report]
|
|
27
|
+
def build_report(root:, files:, churn:, worst_methods: DEFAULT_WORST_METHODS,
|
|
28
|
+
git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil)
|
|
29
|
+
hotspots = files.filter_map do |abs|
|
|
30
|
+
hotspot_for(abs, root: root, churn: churn, worst_methods: worst_methods)
|
|
31
|
+
end
|
|
32
|
+
hotspots.sort_by! { |h| [-h.score, -h.complexity, h.path] }
|
|
33
|
+
|
|
34
|
+
Report.new(
|
|
35
|
+
root: root,
|
|
36
|
+
hotspots: hotspots,
|
|
37
|
+
git_ref: git_ref,
|
|
38
|
+
generated_at: generated_at,
|
|
39
|
+
churn_window: churn_window,
|
|
40
|
+
churn_since: churn_since
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Report::Hotspot, nil] nil when the file has no scoring methods
|
|
45
|
+
def hotspot_for(abs, root:, churn:, worst_methods:)
|
|
46
|
+
rel = relative_path(abs, root)
|
|
47
|
+
methods = Parser.parse_file(abs).map { |m| build_method(m, rel) }
|
|
48
|
+
complexity = methods.sum(0.0, &:abc)
|
|
49
|
+
return nil if complexity.zero?
|
|
50
|
+
|
|
51
|
+
churn_count = churn[rel]
|
|
52
|
+
kept = methods.sort_by { |m| -m.abc }.first(worst_methods)
|
|
53
|
+
|
|
54
|
+
Report::Hotspot.new(
|
|
55
|
+
path: rel,
|
|
56
|
+
score: combine(complexity, churn_count).round(2),
|
|
57
|
+
complexity: complexity.round(2),
|
|
58
|
+
churn: churn_count,
|
|
59
|
+
methods: kept
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The v0.1 scoring rule. Swap-point for future normalisation.
|
|
64
|
+
# @return [Numeric]
|
|
65
|
+
def combine(complexity, churn)
|
|
66
|
+
complexity * churn
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_method(method_def, rel)
|
|
70
|
+
Report::Method.new(
|
|
71
|
+
symbol_id: SymbolId.for(path: rel, start_line: method_def.span.start_line, fqname: method_def.name),
|
|
72
|
+
name: method_def.name,
|
|
73
|
+
span: method_def.span,
|
|
74
|
+
abc: ABC.score(method_def.node)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def relative_path(abs, root)
|
|
79
|
+
SymbolId.relative_path(abs, root)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/moult/span.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moult
|
|
4
|
+
# A source location range for a definition. Lines are 1-based, columns are
|
|
5
|
+
# 0-based, matching Prism's location offsets. Part of the protected JSON
|
|
6
|
+
# contract and a component of a method's Phase 3 coverage join key.
|
|
7
|
+
Span = Struct.new(:start_line, :start_column, :end_line, :end_column) do
|
|
8
|
+
def to_h
|
|
9
|
+
{
|
|
10
|
+
start_line: start_line,
|
|
11
|
+
start_column: start_column,
|
|
12
|
+
end_line: end_line,
|
|
13
|
+
end_column: end_column
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# Builds the stable symbol id used as the cross-analysis join key:
|
|
7
|
+
# "<path>:<start_line>:<fqname>" (path relative to the analysis root,
|
|
8
|
+
# start_line 1-based). Shared by {Scoring} (hotspots) and {Index} (dead code)
|
|
9
|
+
# so the two analyses mint identical ids for the same definition and the
|
|
10
|
+
# Phase 3 coverage merge can join them. Centralised here so the format cannot
|
|
11
|
+
# drift between producers.
|
|
12
|
+
module SymbolId
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param path [String] path relative to the analysis root
|
|
16
|
+
# @param start_line [Integer] 1-based definition line
|
|
17
|
+
# @param fqname [String] fully-qualified lexical name (Class#method / Class.method)
|
|
18
|
+
# @return [String]
|
|
19
|
+
def for(path:, start_line:, fqname:)
|
|
20
|
+
"#{path}:#{start_line}:#{fqname}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param abs [String] absolute path
|
|
24
|
+
# @param root [String] absolute analysis root
|
|
25
|
+
# @return [String] path relative to root
|
|
26
|
+
def relative_path(abs, root)
|
|
27
|
+
Pathname.new(abs).relative_path_from(Pathname.new(root)).to_s
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Moult
|
|
6
|
+
# Collects method names that are referenced *as symbols* passed to Rails-style
|
|
7
|
+
# DSL methods — `before_action :authenticate`, `validate :check`,
|
|
8
|
+
# `helper_method :current_user`, `delegate :name, to: :user`. rubydex's
|
|
9
|
+
# reference index only sees real call sites, so these symbol arguments look
|
|
10
|
+
# like nothing references the method and it would be a false-positive
|
|
11
|
+
# dead-code candidate. This scanner harvests them so {RailsConventions} can
|
|
12
|
+
# treat the named methods as live.
|
|
13
|
+
#
|
|
14
|
+
# It is intentionally name-based and lexically scoped to the enclosing
|
|
15
|
+
# class/module: it returns the set of bare method names referenced by DSL
|
|
16
|
+
# symbols in a file, qualified by the surrounding namespace where known. Over-
|
|
17
|
+
# collecting is safe — a spurious "reference" only lowers a finding's
|
|
18
|
+
# confidence, it can never invent a finding.
|
|
19
|
+
module SymbolScanner
|
|
20
|
+
# DSL methods whose Symbol arguments name a method of the surrounding class.
|
|
21
|
+
CALLBACK_DSL = %w[
|
|
22
|
+
before_action after_action around_action
|
|
23
|
+
append_before_action prepend_before_action
|
|
24
|
+
skip_before_action skip_after_action skip_around_action
|
|
25
|
+
before_filter after_filter around_filter
|
|
26
|
+
before_save after_save before_create after_create
|
|
27
|
+
before_update after_update before_destroy after_destroy
|
|
28
|
+
before_validation after_validation
|
|
29
|
+
after_commit after_rollback after_initialize after_find
|
|
30
|
+
before_action_callback
|
|
31
|
+
validate validates_each
|
|
32
|
+
helper_method
|
|
33
|
+
scope delegate
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param source [String] Ruby source
|
|
39
|
+
# @return [Array<String>] referenced names: bare ("authenticate") and, where
|
|
40
|
+
# a lexical namespace is known, qualified ("Foo::Bar#authenticate").
|
|
41
|
+
def scan_source(source)
|
|
42
|
+
result = Prism.parse(source)
|
|
43
|
+
visitor = Visitor.new
|
|
44
|
+
result.value.accept(visitor)
|
|
45
|
+
visitor.referenced_names.to_a
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param path [String]
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
def scan_file(path)
|
|
51
|
+
scan_source(File.read(path))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Walks the tree tracking lexical nesting (mirroring {Parser::Visitor}) so a
|
|
55
|
+
# collected symbol can be attributed to its enclosing class.
|
|
56
|
+
class Visitor < Prism::Visitor
|
|
57
|
+
attr_reader :referenced_names
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@namespace = []
|
|
61
|
+
@referenced_names = []
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def visit_class_node(node)
|
|
66
|
+
@namespace.push(node.constant_path.slice)
|
|
67
|
+
super
|
|
68
|
+
@namespace.pop
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def visit_module_node(node)
|
|
72
|
+
@namespace.push(node.constant_path.slice)
|
|
73
|
+
super
|
|
74
|
+
@namespace.pop
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def visit_call_node(node)
|
|
78
|
+
collect(node) if CALLBACK_DSL.include?(node.name.to_s)
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def collect(node)
|
|
85
|
+
symbol_arguments(node).each do |sym|
|
|
86
|
+
@referenced_names << sym
|
|
87
|
+
qualifier = @namespace.join("::")
|
|
88
|
+
@referenced_names << "#{qualifier}##{sym}" unless qualifier.empty?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def symbol_arguments(node)
|
|
93
|
+
args = node.arguments&.arguments || []
|
|
94
|
+
args.filter_map do |arg|
|
|
95
|
+
arg.unescaped if arg.is_a?(Prism::SymbolNode)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/moult.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "moult/version"
|
|
4
|
+
|
|
5
|
+
# Moult provides confidence-graded codebase intelligence for Ruby.
|
|
6
|
+
#
|
|
7
|
+
# Phase 1 exposes a single capability: ranking files by a complexity x churn
|
|
8
|
+
# hotspot score. See {Moult::CLI} for the command-line entrypoint.
|
|
9
|
+
module Moult
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
autoload :CLI, "moult/cli"
|
|
13
|
+
autoload :Parser, "moult/parser"
|
|
14
|
+
autoload :MethodDef, "moult/parser"
|
|
15
|
+
autoload :ABC, "moult/abc"
|
|
16
|
+
autoload :Churn, "moult/churn"
|
|
17
|
+
autoload :Git, "moult/git"
|
|
18
|
+
autoload :Scoring, "moult/scoring"
|
|
19
|
+
autoload :Discovery, "moult/discovery"
|
|
20
|
+
autoload :Report, "moult/report"
|
|
21
|
+
autoload :Span, "moult/span"
|
|
22
|
+
autoload :SymbolId, "moult/symbol_id"
|
|
23
|
+
|
|
24
|
+
# Phase 2: confidence-graded dead-code analysis.
|
|
25
|
+
autoload :Index, "moult/index"
|
|
26
|
+
autoload :Confidence, "moult/confidence"
|
|
27
|
+
autoload :DeadCode, "moult/dead_code"
|
|
28
|
+
autoload :DeadCodeReport, "moult/dead_code_report"
|
|
29
|
+
autoload :RailsConventions, "moult/rails_conventions"
|
|
30
|
+
autoload :SymbolScanner, "moult/symbol_scanner"
|
|
31
|
+
|
|
32
|
+
# Phase 3: runtime coverage layer (static<->runtime merge).
|
|
33
|
+
autoload :Coverage, "moult/coverage"
|
|
34
|
+
autoload :CoverageReport, "moult/coverage_report"
|
|
35
|
+
|
|
36
|
+
# Static slice: flay-backed structural duplication detection.
|
|
37
|
+
autoload :Clones, "moult/clones"
|
|
38
|
+
autoload :Duplication, "moult/duplication"
|
|
39
|
+
autoload :DuplicationReport, "moult/duplication_report"
|
|
40
|
+
|
|
41
|
+
# Health slice: a composite health score aggregating the other analyses.
|
|
42
|
+
autoload :Health, "moult/health"
|
|
43
|
+
autoload :HealthReport, "moult/health_report"
|
|
44
|
+
|
|
45
|
+
# Static slice: packwerk-backed architecture-boundary violations.
|
|
46
|
+
autoload :Boundaries, "moult/boundaries"
|
|
47
|
+
autoload :BoundariesReport, "moult/boundaries_report"
|
|
48
|
+
|
|
49
|
+
# Static slice: OpenFeature feature-flag usage (provider-agnostic).
|
|
50
|
+
autoload :FlagScanner, "moult/flag_scanner"
|
|
51
|
+
autoload :Flags, "moult/flags"
|
|
52
|
+
autoload :FlagsReport, "moult/flags_report"
|
|
53
|
+
|
|
54
|
+
# Phase 4 (core): the diff-aware PR risk gate — the first/only verdict layer.
|
|
55
|
+
autoload :Diff, "moult/diff"
|
|
56
|
+
autoload :Gate, "moult/gate"
|
|
57
|
+
autoload :GateReport, "moult/gate_report"
|
|
58
|
+
|
|
59
|
+
# Cloud upload: sanitising projection for the moult-action GitHub Action.
|
|
60
|
+
autoload :CloudUpload, "moult/cloud_upload"
|
|
61
|
+
|
|
62
|
+
module Formatters
|
|
63
|
+
autoload :TextTable, "moult/formatters/text_table"
|
|
64
|
+
autoload :Table, "moult/formatters/table"
|
|
65
|
+
autoload :Json, "moult/formatters/json"
|
|
66
|
+
autoload :DeadCodeTable, "moult/formatters/dead_code_table"
|
|
67
|
+
autoload :DeadCodeJson, "moult/formatters/dead_code_json"
|
|
68
|
+
autoload :CoverageTable, "moult/formatters/coverage_table"
|
|
69
|
+
autoload :CoverageJson, "moult/formatters/coverage_json"
|
|
70
|
+
autoload :DuplicationTable, "moult/formatters/duplication_table"
|
|
71
|
+
autoload :DuplicationJson, "moult/formatters/duplication_json"
|
|
72
|
+
autoload :HealthTable, "moult/formatters/health_table"
|
|
73
|
+
autoload :HealthJson, "moult/formatters/health_json"
|
|
74
|
+
autoload :BoundariesTable, "moult/formatters/boundaries_table"
|
|
75
|
+
autoload :BoundariesJson, "moult/formatters/boundaries_json"
|
|
76
|
+
autoload :FlagsTable, "moult/formatters/flags_table"
|
|
77
|
+
autoload :FlagsJson, "moult/formatters/flags_json"
|
|
78
|
+
autoload :GateMessage, "moult/formatters/gate_message"
|
|
79
|
+
autoload :GateTable, "moult/formatters/gate_table"
|
|
80
|
+
autoload :GateJson, "moult/formatters/gate_json"
|
|
81
|
+
autoload :GateGithub, "moult/formatters/gate_github"
|
|
82
|
+
autoload :GateSarif, "moult/formatters/gate_sarif"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/moult-rb/moult-rb/blob/main/schema/boundaries.schema.json",
|
|
4
|
+
"title": "Moult architecture-boundaries report",
|
|
5
|
+
"description": "Typed output contract for `moult boundaries`. One of Moult's two protected APIs. Each finding is a recorded packwerk boundary violation, classified by severity. A violation is a recorded fact (packwerk resolved the constant via Zeitwerk and verified it crosses a declared boundary), so confidence is null and severity carries the signal; nothing here asserts the code is certainly wrong.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "tool", "analysis", "summary", "findings"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {
|
|
11
|
+
"description": "Bumped only on a breaking change to this shape.",
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"const": 1
|
|
14
|
+
},
|
|
15
|
+
"tool": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["name", "version"],
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": {"type": "string"},
|
|
21
|
+
"version": {"type": "string"}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"analysis": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["root", "git_ref", "generated_at", "detector"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"root": {"type": "string"},
|
|
30
|
+
"git_ref": {"type": ["string", "null"]},
|
|
31
|
+
"generated_at": {"type": ["string", "null"], "format": "date-time"},
|
|
32
|
+
"detector": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"required": ["backend", "backend_version", "configured"],
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"properties": {
|
|
37
|
+
"backend": {"description": "Boundary backend, e.g. \"packwerk\".", "type": "string"},
|
|
38
|
+
"backend_version": {"type": ["string", "null"]},
|
|
39
|
+
"configured": {"description": "Whether the project is packwerk-configured (a packwerk.yml exists). When false, findings is empty.", "type": "boolean"}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"summary": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"required": ["findings", "violations", "by_type", "by_severity"],
|
|
47
|
+
"additionalProperties": false,
|
|
48
|
+
"properties": {
|
|
49
|
+
"findings": {"description": "Number of violation groups.", "type": "integer", "minimum": 0},
|
|
50
|
+
"violations": {"description": "Total referencing sites across all groups.", "type": "integer", "minimum": 0},
|
|
51
|
+
"by_type": {"description": "Violation count keyed by violation type.", "type": "object", "additionalProperties": {"type": "integer", "minimum": 0}},
|
|
52
|
+
"by_severity": {"description": "Violation count keyed by severity.", "type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"findings": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"items": {"$ref": "#/$defs/finding"}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"$defs": {
|
|
61
|
+
"reason": {
|
|
62
|
+
"description": "One auditable note behind a finding's severity classification.",
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["rule", "detail"],
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"properties": {
|
|
67
|
+
"rule": {"type": "string"},
|
|
68
|
+
"detail": {"type": "string"}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"occurrence": {
|
|
72
|
+
"description": "One referencing site of a violation group.",
|
|
73
|
+
"type": "object",
|
|
74
|
+
"required": ["symbol_id", "path"],
|
|
75
|
+
"additionalProperties": false,
|
|
76
|
+
"properties": {
|
|
77
|
+
"symbol_id": {
|
|
78
|
+
"description": "Method-level join key (\"<path>:<start_line>:<fqname>\"), shared with the other contracts. Null in this slice: packwerk's recorded violations are file-keyed (no line numbers), so no enclosing method is resolved. Findings join at path granularity. Kept nullable for forward-compatibility with line-level offenses.",
|
|
79
|
+
"type": ["string", "null"]
|
|
80
|
+
},
|
|
81
|
+
"path": {"description": "Root-relative referencing file path; the health files[] join key.", "type": "string"}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"finding": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"required": ["category", "confidence", "violation_type", "severity", "referencing_package", "defining_package", "constant", "reasons", "occurrences"],
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"properties": {
|
|
89
|
+
"category": {
|
|
90
|
+
"description": "Always \"architecture_boundary\" in this contract.",
|
|
91
|
+
"type": "string",
|
|
92
|
+
"const": "architecture_boundary"
|
|
93
|
+
},
|
|
94
|
+
"confidence": {
|
|
95
|
+
"description": "Always null here: a packwerk violation is a recorded fact, not a probabilistic candidate. The per-finding signal is carried by `severity`, never by a manufactured confidence.",
|
|
96
|
+
"$ref": "common.schema.json#/$defs/confidence"
|
|
97
|
+
},
|
|
98
|
+
"violation_type": {
|
|
99
|
+
"description": "The packwerk violation kind.",
|
|
100
|
+
"type": "string",
|
|
101
|
+
"enum": ["dependency", "privacy", "visibility", "folder_privacy", "layer"]
|
|
102
|
+
},
|
|
103
|
+
"severity": {
|
|
104
|
+
"description": "How architecturally significant the violation kind is. A classification of the recorded fact, never an assertion the code is wrong.",
|
|
105
|
+
"type": "string",
|
|
106
|
+
"enum": ["low", "medium", "high"]
|
|
107
|
+
},
|
|
108
|
+
"referencing_package": {"description": "The package making the boundary-crossing reference (\".\" for the root package).", "type": "string"},
|
|
109
|
+
"defining_package": {"description": "The package that owns the referenced constant.", "type": "string"},
|
|
110
|
+
"constant": {"description": "The constant that crosses the boundary.", "type": "string"},
|
|
111
|
+
"reasons": {
|
|
112
|
+
"description": "The notes behind the severity classification.",
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": {"$ref": "#/$defs/reason"}
|
|
115
|
+
},
|
|
116
|
+
"occurrences": {
|
|
117
|
+
"description": "The referencing sites of this violation group; at least one.",
|
|
118
|
+
"type": "array",
|
|
119
|
+
"minItems": 1,
|
|
120
|
+
"items": {"$ref": "#/$defs/occurrence"}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/moult-rb/moult-rb/blob/main/schema/common.schema.json",
|
|
4
|
+
"title": "Moult shared definitions",
|
|
5
|
+
"description": "Definitions shared across Moult's protected output contracts (hotspots, deadcode) so the confidence model and source spans cannot drift between them.",
|
|
6
|
+
"$defs": {
|
|
7
|
+
"unit_interval": {
|
|
8
|
+
"description": "A confidence in [0, 1]. Findings are confidence-graded, never asserted as certain death.",
|
|
9
|
+
"type": "number",
|
|
10
|
+
"minimum": 0,
|
|
11
|
+
"maximum": 1
|
|
12
|
+
},
|
|
13
|
+
"confidence": {
|
|
14
|
+
"description": "Reserved nullable confidence slot. Null where an analysis does not (yet) assert a confidence; otherwise a value in [0, 1].",
|
|
15
|
+
"oneOf": [
|
|
16
|
+
{"type": "null"},
|
|
17
|
+
{"$ref": "#/$defs/unit_interval"}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"category": {
|
|
21
|
+
"description": "Finding classification (e.g. \"dead_code\"). Null where unclassified.",
|
|
22
|
+
"type": ["string", "null"]
|
|
23
|
+
},
|
|
24
|
+
"span": {
|
|
25
|
+
"description": "Definition source range. Lines 1-based, columns 0-based (Prism offsets).",
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["start_line", "start_column", "end_line", "end_column"],
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"properties": {
|
|
30
|
+
"start_line": {"type": "integer", "minimum": 1},
|
|
31
|
+
"start_column": {"type": "integer", "minimum": 0},
|
|
32
|
+
"end_line": {"type": "integer", "minimum": 1},
|
|
33
|
+
"end_column": {"type": "integer", "minimum": 0}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"runtime": {
|
|
37
|
+
"description": "Runtime coverage classification for a symbol. hot=an executable body line ran; cold=tracked file, no body line ran; untracked=no signal (file absent from the dataset, a constant, or unclassifiable). Null where no coverage was merged. Evidence, never an assertion of certain death.",
|
|
38
|
+
"oneOf": [
|
|
39
|
+
{"type": "null"},
|
|
40
|
+
{"type": "string", "enum": ["hot", "cold", "untracked"]}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"coverage_source": {
|
|
44
|
+
"description": "Provenance of merged runtime coverage. Null where no coverage was merged.",
|
|
45
|
+
"oneOf": [
|
|
46
|
+
{"type": "null"},
|
|
47
|
+
{
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": ["backend", "version", "collected_at"],
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"properties": {
|
|
52
|
+
"backend": {"description": "Coverage backend, e.g. \"simplecov\" or \"coverage\".", "type": "string"},
|
|
53
|
+
"version": {"type": ["string", "null"]},
|
|
54
|
+
"collected_at": {"type": ["string", "null"], "format": "date-time"}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"provider_source": {
|
|
60
|
+
"description": "Provenance of a merged feature-flag provider snapshot (--provider). Null where no snapshot was merged. The flags analogue of coverage_source: evidence about which keys the provider knows, never a claim a flag is certainly stale.",
|
|
61
|
+
"oneOf": [
|
|
62
|
+
{"type": "null"},
|
|
63
|
+
{
|
|
64
|
+
"type": "object",
|
|
65
|
+
"required": ["backend", "version", "exported_at"],
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"properties": {
|
|
68
|
+
"backend": {"description": "Provider snapshot backend, e.g. \"flagd\".", "type": "string"},
|
|
69
|
+
"version": {"type": ["string", "null"]},
|
|
70
|
+
"exported_at": {"type": ["string", "null"], "format": "date-time"}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/moult-rb/moult-rb/blob/main/schema/coverage.schema.json",
|
|
4
|
+
"title": "Moult runtime coverage map",
|
|
5
|
+
"description": "Typed output contract for `moult coverage`: a per-symbol hot/cold/untracked map produced by resolving line-keyed coverage to definition spans. A diagnostic view of the same runtime evidence `moult deadcode --coverage` merges into confidence; it makes no dead-code claim.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "tool", "analysis", "summary", "symbols"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {
|
|
11
|
+
"description": "Bumped only on a breaking change to this shape.",
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"const": 1
|
|
14
|
+
},
|
|
15
|
+
"tool": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["name", "version"],
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": {"type": "string"},
|
|
21
|
+
"version": {"type": "string"}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"analysis": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["root", "git_ref", "generated_at", "coverage", "index"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"root": {"type": "string"},
|
|
30
|
+
"git_ref": {"type": ["string", "null"]},
|
|
31
|
+
"generated_at": {"type": ["string", "null"], "format": "date-time"},
|
|
32
|
+
"coverage": {"$ref": "common.schema.json#/$defs/coverage_source"},
|
|
33
|
+
"index": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"required": ["backend", "backend_version", "resolved", "diagnostics"],
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"properties": {
|
|
38
|
+
"backend": {"type": "string"},
|
|
39
|
+
"backend_version": {"type": ["string", "null"]},
|
|
40
|
+
"resolved": {"type": "boolean"},
|
|
41
|
+
"diagnostics": {"type": "array", "items": {"type": "string"}}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"summary": {
|
|
47
|
+
"description": "Counts of symbols by runtime classification.",
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": ["hot", "cold", "untracked"],
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"properties": {
|
|
52
|
+
"hot": {"type": "integer", "minimum": 0},
|
|
53
|
+
"cold": {"type": "integer", "minimum": 0},
|
|
54
|
+
"untracked": {"type": "integer", "minimum": 0}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"symbols": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": {"$ref": "#/$defs/symbol"}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"$defs": {
|
|
63
|
+
"symbol": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"required": ["symbol_id", "kind", "name", "span", "runtime"],
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"properties": {
|
|
68
|
+
"symbol_id": {
|
|
69
|
+
"description": "Stable join key shared with the hotspots and deadcode contracts: \"<path>:<start_line>:<fqname>\".",
|
|
70
|
+
"type": "string"
|
|
71
|
+
},
|
|
72
|
+
"kind": {"type": "string", "enum": ["method", "constant"]},
|
|
73
|
+
"name": {"type": "string"},
|
|
74
|
+
"span": {"$ref": "common.schema.json#/$defs/span"},
|
|
75
|
+
"runtime": {
|
|
76
|
+
"description": "hot/cold/untracked. Never null here: `moult coverage` always merges a dataset.",
|
|
77
|
+
"type": "string",
|
|
78
|
+
"enum": ["hot", "cold", "untracked"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|