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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ VERSION = "0.1.0"
5
+ 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
+ }