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
data/lib/moult/abc.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Moult
6
+ # Flog-style weighted ABC complexity for a single method.
7
+ #
8
+ # This is *not* the bare ABC metric (sqrt(A^2 + B^2 + C^2)). Following flog,
9
+ # the score is a weighted *sum* of three buckets, with metaprogramming calls
10
+ # penalised and a compounding depth penalty for nesting:
11
+ #
12
+ # * A - Assignments: any write node (`=`, op-assign, `||=`, multi-assign,
13
+ # `obj.x =`, `arr[i] =`). Weight {ASSIGNMENT}. Counted once per write node.
14
+ # * B - Branches: every message send ({Prism::CallNode}, including operators
15
+ # like `+` and `==` and index `[]`), plus `yield` and `super`. Weight
16
+ # {BRANCH}, except metaprogramming calls in {MAGIC_CALLS}, which weigh more.
17
+ # * C - Conditions: decision nodes - if/unless/while/until/for, case + each
18
+ # when/in, rescue, and `&&`/`||`. Weight {CONDITION}.
19
+ #
20
+ # Depth penalty: contributions nested inside a control structure or block are
21
+ # multiplied by {DEPTH_FACTOR} per level, compounding. A call directly in the
22
+ # method body weighs 1.0; the same call one `if` deep weighs 1.1; two deep,
23
+ # 1.21; and so on.
24
+ #
25
+ # flog is the reference for the *shape* of this metric; the exact weights below
26
+ # are the ones Moult adopts and are pinned by hand-counted fixtures. Treat any
27
+ # drift from those fixtures as a metric bug.
28
+ module ABC
29
+ ASSIGNMENT = 1.0
30
+ BRANCH = 1.0
31
+ CONDITION = 1.0
32
+
33
+ # Each level of control-flow / block nesting compounds contributions by 10%.
34
+ DEPTH_FACTOR = 1.1
35
+
36
+ # Metaprogramming and dynamic-dispatch calls weigh more than an ordinary
37
+ # send, mirroring flog's penalties for hard-to-follow Ruby.
38
+ MAGIC_CALLS = {
39
+ eval: 5.0,
40
+ instance_eval: 5.0,
41
+ class_eval: 5.0,
42
+ module_eval: 5.0,
43
+ class_exec: 5.0,
44
+ instance_exec: 5.0,
45
+ define_method: 4.0,
46
+ define_singleton_method: 4.0,
47
+ method_missing: 4.0,
48
+ alias_method: 2.0,
49
+ send: 3.0,
50
+ __send__: 3.0,
51
+ public_send: 3.0
52
+ }.freeze
53
+
54
+ BRANCH_NODES = [
55
+ Prism::CallNode,
56
+ Prism::YieldNode,
57
+ Prism::SuperNode,
58
+ Prism::ForwardingSuperNode
59
+ ].freeze
60
+
61
+ CONDITION_NODES = [
62
+ Prism::IfNode,
63
+ Prism::UnlessNode,
64
+ Prism::WhileNode,
65
+ Prism::UntilNode,
66
+ Prism::ForNode,
67
+ Prism::CaseNode,
68
+ Prism::CaseMatchNode,
69
+ Prism::WhenNode,
70
+ Prism::InNode,
71
+ Prism::RescueNode,
72
+ Prism::AndNode,
73
+ Prism::OrNode
74
+ ].freeze
75
+
76
+ # Nodes whose children sit one nesting level deeper. Containers only - the
77
+ # when/in/&&/|| conditions don't bump again on top of their container.
78
+ NESTING_NODES = [
79
+ Prism::IfNode,
80
+ Prism::UnlessNode,
81
+ Prism::WhileNode,
82
+ Prism::UntilNode,
83
+ Prism::ForNode,
84
+ Prism::CaseNode,
85
+ Prism::CaseMatchNode,
86
+ Prism::RescueNode,
87
+ Prism::BlockNode,
88
+ Prism::LambdaNode
89
+ ].freeze
90
+
91
+ module_function
92
+
93
+ # @param def_node [Prism::DefNode] a method definition
94
+ # @return [Float] the method's weighted ABC score, rounded to 2 decimals
95
+ def score(def_node)
96
+ total = walk(def_node, 1.0, root: true)
97
+ total.round(2)
98
+ end
99
+
100
+ # Recursively accumulate weighted contributions. Nested `def`s are scored
101
+ # independently (they're separate methods), so we don't descend into them.
102
+ def walk(node, multiplier, root: false)
103
+ return 0.0 if node.is_a?(Prism::DefNode) && !root
104
+
105
+ total = weight_for(node) * multiplier
106
+ child_multiplier = NESTING_NODES.include?(node.class) ? multiplier * DEPTH_FACTOR : multiplier
107
+ node.compact_child_nodes.each do |child|
108
+ total += walk(child, child_multiplier)
109
+ end
110
+ total
111
+ end
112
+
113
+ # The weight this node itself contributes (before the depth multiplier).
114
+ def weight_for(node)
115
+ case node
116
+ when Prism::CallNode
117
+ MAGIC_CALLS.fetch(node.name, BRANCH)
118
+ else
119
+ return BRANCH if BRANCH_NODES.include?(node.class)
120
+ return ASSIGNMENT if assignment?(node)
121
+ return CONDITION if CONDITION_NODES.include?(node.class)
122
+
123
+ 0.0
124
+ end
125
+ end
126
+
127
+ # Every Prism assignment node class ends in "WriteNode" (plain writes,
128
+ # operator writes, ||=/&&= writes, multi-writes, and index/attr writes).
129
+ def assignment?(node)
130
+ node.class.name.end_with?("WriteNode")
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../symbol_id"
5
+
6
+ module Moult
7
+ module Boundaries
8
+ # The architecture-boundary adapter — Moult's reader of Packwerk's on-disk
9
+ # artifacts and the *only* file that names Packwerk. Everything downstream
10
+ # consumes the Moult-owned {Violation}/{Result} value objects, never a packwerk
11
+ # type, so the backend is swappable (the "swap, not rewrite" invariant).
12
+ #
13
+ # Like {Coverage} (which ingests SimpleCov/stdlib coverage *files*), this slice
14
+ # ingests packwerk's *files* rather than booting it: a live `bin/packwerk check`
15
+ # needs a bootable Rails/Zeitwerk app and emits only human prose / new-violation
16
+ # deltas, whereas packwerk *serialises every recorded violation* to stable,
17
+ # diffable `package_todo.yml` files. We read those (the package graph + the
18
+ # recorded violations packwerk already resolved via Zeitwerk) and own no part of
19
+ # the constant-resolution graph. Consequently Moult needs NO packwerk gem
20
+ # dependency (exactly as {Coverage} needs no simplecov). Live re-analysis — the
21
+ # fresh, line-level offense set — is deferred, the same way the Coverband and
22
+ # Flipper live stores are.
23
+ #
24
+ # The `package_todo.yml` shape we parse (packwerk's own serialization):
25
+ #
26
+ # <defining-package>: # the package that OWNS the referenced constant
27
+ # "::Some::Constant": # the constant crossing the boundary
28
+ # violations:
29
+ # - dependency # one or more violation types
30
+ # - privacy
31
+ # files:
32
+ # - path/to/referencing.rb # the referencing files (root-relative)
33
+ #
34
+ # The file lives at `<referencing-package-dir>/package_todo.yml`, so the
35
+ # referencing package is the file's directory (root-relative; "." for the root
36
+ # package). packwerk reports violations at FILE granularity (no line numbers),
37
+ # which fixes this slice's join at path level.
38
+ module Packwerk
39
+ module_function
40
+
41
+ # A single recorded boundary violation: one referencing file crossing into one
42
+ # constant owned by another package, of one type. Path is root-relative.
43
+ Violation = Struct.new(:violation_type, :referencing_package, :defining_package, :constant, :path)
44
+
45
+ # The Moult-owned result of reading a project's packwerk artifacts. +configured+
46
+ # is false when the project has no `packwerk.yml` (not a packwerk project), in
47
+ # which case +violations+ is empty. +backend+/+backend_version+ originate here so
48
+ # "packwerk" stays isolated to this file.
49
+ Result = Struct.new(:violations, :backend, :backend_version, :configured)
50
+
51
+ # @param root [String] absolute analysis root
52
+ # @return [Result]
53
+ def detect(root:)
54
+ unless configured?(root)
55
+ return Result.new(violations: [], backend: "packwerk", backend_version: backend_version, configured: false)
56
+ end
57
+
58
+ violations = todo_files(root).flat_map { |file| violations_in(file, root) }
59
+ Result.new(violations: violations, backend: "packwerk", backend_version: backend_version, configured: true)
60
+ end
61
+
62
+ # A `packwerk.yml` at the root is the unambiguous "this is a packwerk project"
63
+ # marker (it is required for any packwerk run).
64
+ def configured?(root)
65
+ File.exist?(File.join(root, "packwerk.yml"))
66
+ end
67
+
68
+ def todo_files(root)
69
+ Dir.glob(File.join(root, "**", "package_todo.yml")).sort
70
+ end
71
+
72
+ # Parse one `package_todo.yml` into flat {Violation}s. The referencing package
73
+ # is the file's directory (root-relative). A malformed/empty file is skipped
74
+ # rather than crashing the whole run.
75
+ def violations_in(file, root)
76
+ referencing_package = package_name(File.dirname(file), root)
77
+ data = YAML.safe_load_file(file)
78
+ return [] unless data.is_a?(Hash)
79
+
80
+ data.flat_map do |defining_package, constants|
81
+ next [] unless constants.is_a?(Hash)
82
+ constants.flat_map do |constant, detail|
83
+ next [] unless detail.is_a?(Hash)
84
+ types = Array(detail["violations"])
85
+ paths = Array(detail["files"])
86
+ types.product(paths).map do |type, path|
87
+ Violation.new(
88
+ violation_type: type.to_s,
89
+ referencing_package: referencing_package,
90
+ defining_package: defining_package.to_s,
91
+ constant: constant.to_s,
92
+ path: path.to_s
93
+ )
94
+ end
95
+ end
96
+ end
97
+ rescue Psych::Exception
98
+ []
99
+ end
100
+
101
+ # Root-relative package name; "." for the root package (packwerk's convention).
102
+ def package_name(dir, root)
103
+ SymbolId.relative_path(dir, root)
104
+ end
105
+
106
+ # packwerk is not a Moult dependency, so its constant is normally absent; the
107
+ # version is recorded when it happens to be loaded, else nil (nullable in the
108
+ # contract). This is the only reference to the Packwerk constant in Moult.
109
+ def backend_version
110
+ defined?(::Packwerk::VERSION) ? ::Packwerk::VERSION : nil
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ module Boundaries
5
+ # The per-finding model for architecture boundaries — this slice's realisation
6
+ # of Moult's protected per-finding API. Unlike dead code, a packwerk violation
7
+ # is not a probabilistic guess: packwerk resolved the constant via Zeitwerk and
8
+ # verified it crosses a *declared* boundary, so the honest grade here is a
9
+ # SEVERITY classification, not a confidence. We never manufacture a fake 1.0
10
+ # confidence (which would carry no information); the finding's +confidence+ is
11
+ # null and {classify} assigns a severity by violation kind instead.
12
+ #
13
+ # This keeps the humility invariant in a different register: we still never
14
+ # overstate. A "severity" says how architecturally significant the *kind* of
15
+ # boundary crossing is — it does not assert the code is wrong, only that
16
+ # packwerk recorded a declared-boundary violation of that kind.
17
+ #
18
+ # {classify} is a pure function of the violation type — no IO, no packwerk
19
+ # objects — so it is pinned against hand-built inputs exactly like {ABC}, the
20
+ # coverage {Resolver}, and the duplication {Confidence} model. Drift is a bug.
21
+ module Severity
22
+ CATEGORY = "architecture_boundary"
23
+
24
+ # The ordered severity scale (least → most architecturally significant).
25
+ SCALE = %w[low medium high].freeze
26
+
27
+ # Pinned severity per packwerk violation type. Dependency and layer crossings
28
+ # break the *declared* dependency graph — the core architectural contract —
29
+ # so they rank highest. Privacy/visibility/folder_privacy are reaches past a
30
+ # package's public surface: real violations, but a narrower contract, so
31
+ # medium. An unrecognised type degrades to +low+ (we never drop it).
32
+ SEVERITY = {
33
+ "dependency" => "high",
34
+ "layer" => "high",
35
+ "privacy" => "medium",
36
+ "visibility" => "medium",
37
+ "folder_privacy" => "medium"
38
+ }.freeze
39
+
40
+ DEFAULT_SEVERITY = "low"
41
+
42
+ # Numeric weight per severity, consumed by the health composite to turn a set
43
+ # of violations into a per-file badness burden. Pinned alongside SEVERITY so
44
+ # the health boundaries component stays deterministic.
45
+ SEVERITY_WEIGHT = {"high" => 1.0, "medium" => 0.6, "low" => 0.3}.freeze
46
+
47
+ # One auditable note behind a classification. Mirrors the shared rule/detail
48
+ # reason shape, but a severity is categorical (not a delta-sum), so it carries
49
+ # no +delta+. Kept local so the boundaries slice does not couple to the
50
+ # dead-code or duplication Reason structs.
51
+ Reason = Struct.new(:rule, :detail) do
52
+ def to_h
53
+ {rule: rule.to_s, detail: detail}
54
+ end
55
+ end
56
+
57
+ # The graded result: a severity on {SCALE} and the reasons behind it.
58
+ Assessment = Struct.new(:severity, :reasons)
59
+
60
+ module_function
61
+
62
+ # @param violation_type [String] packwerk violation type, e.g. "dependency"
63
+ # @return [Assessment]
64
+ def classify(violation_type:)
65
+ severity = SEVERITY.fetch(violation_type, DEFAULT_SEVERITY)
66
+ Assessment.new(severity: severity, reasons: [Reason.new(rule: :"#{violation_type}_violation", detail: detail_for(violation_type, severity))])
67
+ end
68
+
69
+ def detail_for(violation_type, severity)
70
+ case violation_type
71
+ when "dependency"
72
+ "references a constant in a package this one does not declare a dependency on (#{severity})"
73
+ when "layer"
74
+ "depends across a declared architecture layer boundary (#{severity})"
75
+ when "privacy"
76
+ "references another package's private (non-public) constant (#{severity})"
77
+ when "visibility"
78
+ "references a package that does not list this one as visible_to (#{severity})"
79
+ when "folder_privacy"
80
+ "references a nested package outside the allowed folder scope (#{severity})"
81
+ else
82
+ "recorded packwerk boundary violation of an unrecognised type (#{severity})"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # Orchestrates the architecture-boundaries analysis: it asks the {Boundaries::Packwerk}
5
+ # adapter for every recorded violation, groups them into findings, and grades each
6
+ # group through the pure {Boundaries::Severity} model. The result is a ranked
7
+ # {BoundariesReport} of confidence-null, severity-classified boundary violations —
8
+ # recorded facts, never claims that the code is wrong.
9
+ #
10
+ # This is the only layer that knows where the facts come from; {Severity} stays a
11
+ # pure function of the violation type so it can be pinned in isolation.
12
+ module Boundaries
13
+ module_function
14
+
15
+ # A finding is one group of violations sharing this identity (the same constant
16
+ # crossing the same package boundary in the same way); its occurrences are the
17
+ # referencing files.
18
+ GROUP_KEY = %i[referencing_package defining_package constant violation_type].freeze
19
+
20
+ # @param root [String] absolute analysis root
21
+ # @param min_severity [String, nil] drop findings below this severity (low<medium<high)
22
+ # @return [BoundariesReport]
23
+ def build_report(root:, min_severity: nil, git_ref: nil, generated_at: nil)
24
+ result = Packwerk.detect(root: root)
25
+
26
+ findings = group(result.violations).map { |key, violations| finding_for(key, violations) }
27
+ findings.select! { |f| meets?(f.severity, min_severity) } if min_severity
28
+ findings.sort_by! { |f| sort_key(f) }
29
+
30
+ BoundariesReport.new(
31
+ root: root,
32
+ findings: findings,
33
+ git_ref: git_ref,
34
+ generated_at: generated_at,
35
+ backend: result.backend,
36
+ backend_version: result.backend_version,
37
+ configured: result.configured
38
+ )
39
+ end
40
+
41
+ def group(violations)
42
+ violations.group_by { |v| GROUP_KEY.map { |k| v[k] } }
43
+ end
44
+
45
+ def finding_for(key, violations)
46
+ referencing_package, defining_package, constant, violation_type = key
47
+ assessment = Severity.classify(violation_type: violation_type)
48
+ occurrences = violations
49
+ .map(&:path).uniq.sort
50
+ .map { |path| BoundariesReport::Occurrence.new(symbol_id: nil, path: path) }
51
+ BoundariesReport::Finding.new(
52
+ violation_type: violation_type,
53
+ severity: assessment.severity,
54
+ referencing_package: referencing_package,
55
+ defining_package: defining_package,
56
+ constant: constant,
57
+ reasons: assessment.reasons,
58
+ occurrences: occurrences
59
+ )
60
+ end
61
+
62
+ # Most-severe first, then a deterministic alphabetical tie-break so output is
63
+ # stable across runs.
64
+ def sort_key(finding)
65
+ [-Severity::SCALE.index(finding.severity), finding.violation_type,
66
+ finding.referencing_package, finding.defining_package, finding.constant]
67
+ end
68
+
69
+ def meets?(severity, floor)
70
+ Severity::SCALE.index(severity) >= Severity::SCALE.index(floor.to_s)
71
+ end
72
+ end
73
+ end
74
+
75
+ require_relative "boundaries/packwerk"
76
+ require_relative "boundaries/severity"
77
+ require_relative "boundaries_report"
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moult
4
+ # The serialized result model for `moult boundaries` (schema/boundaries.schema.json),
5
+ # sibling to {DuplicationReport}, {DeadCodeReport}, {CoverageReport} and {HealthReport}.
6
+ # It owns its own JSON envelope and leaves the other protected contracts untouched.
7
+ #
8
+ # Each {Finding} is one recorded architecture-boundary violation group. Unlike the
9
+ # dead-code/duplication contracts it carries +confidence: null+ (a packwerk violation
10
+ # is a recorded fact, not a probabilistic candidate) and a {Boundaries::Severity}
11
+ # classification instead — the honest per-finding grade for this slice. Nothing here
12
+ # asserts the code is *wrong*, only that packwerk recorded a declared-boundary crossing.
13
+ class BoundariesReport
14
+ # Bump only on a breaking change to the serialized shape.
15
+ SCHEMA_VERSION = 1
16
+
17
+ # One referencing site of a violation group. +path+ (root-relative) is the join
18
+ # key into the health files[] roll-up. +symbol_id+ is the shared method-level
19
+ # join key, but it is NULL in this slice: packwerk's recorded violations are
20
+ # file-keyed (no line numbers), so there is no line to resolve an enclosing
21
+ # method. It is kept (nullable) for contract consistency with the duplication
22
+ # occurrence shape and to stay forward-compatible with line-level offenses.
23
+ Occurrence = Struct.new(:symbol_id, :path) do
24
+ def to_h
25
+ {symbol_id: symbol_id, path: path}
26
+ end
27
+ end
28
+
29
+ # One recorded boundary-violation group: a (referencing_package, defining_package,
30
+ # constant, violation_type) tuple referenced from one or more files. Carries its
31
+ # severity and reasons so the classification is auditable.
32
+ Finding = Struct.new(:violation_type, :severity, :referencing_package, :defining_package,
33
+ :constant, :reasons, :occurrences) do
34
+ def to_h
35
+ {
36
+ category: Boundaries::Severity::CATEGORY,
37
+ confidence: nil,
38
+ violation_type: violation_type,
39
+ severity: severity,
40
+ referencing_package: referencing_package,
41
+ defining_package: defining_package,
42
+ constant: constant,
43
+ reasons: reasons.map(&:to_h),
44
+ occurrences: occurrences.map(&:to_h)
45
+ }
46
+ end
47
+ end
48
+
49
+ attr_reader :root, :findings, :git_ref, :generated_at, :backend, :backend_version, :configured
50
+
51
+ # @param root [String] absolute analysis root
52
+ # @param findings [Array<Finding>] ranked, most-severe first
53
+ # @param backend [String] detector backend name (e.g. "packwerk")
54
+ # @param backend_version [String, nil] backend gem version, when known
55
+ # @param configured [Boolean] whether the project is packwerk-configured
56
+ def initialize(root:, findings:, git_ref: nil, generated_at: nil,
57
+ backend: "packwerk", backend_version: nil, configured: false)
58
+ @root = root
59
+ @findings = findings
60
+ @git_ref = git_ref
61
+ @generated_at = generated_at
62
+ @backend = backend
63
+ @backend_version = backend_version
64
+ @configured = configured
65
+ end
66
+
67
+ # @return [Hash] aggregate counts across all violation groups
68
+ def summary
69
+ {
70
+ findings: findings.size,
71
+ violations: findings.sum { |f| f.occurrences.size },
72
+ by_type: tally { |f| f.violation_type },
73
+ by_severity: tally { |f| f.severity }
74
+ }
75
+ end
76
+
77
+ def to_h
78
+ {
79
+ schema_version: SCHEMA_VERSION,
80
+ tool: {name: "moult", version: Moult::VERSION},
81
+ analysis: {
82
+ root: root,
83
+ git_ref: git_ref,
84
+ generated_at: generated_at,
85
+ detector: {
86
+ backend: backend,
87
+ backend_version: backend_version,
88
+ configured: configured
89
+ }
90
+ },
91
+ summary: summary,
92
+ findings: findings.map(&:to_h)
93
+ }
94
+ end
95
+
96
+ private
97
+
98
+ # Count findings grouped by the yielded key, occurrence-weighted so the totals
99
+ # match the +violations+ count (one violation = one referencing file).
100
+ def tally
101
+ findings.each_with_object(Hash.new(0)) do |finding, acc|
102
+ acc[yield(finding)] += finding.occurrences.size
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git"
4
+
5
+ module Moult
6
+ # Per-file change frequency from git history. "Change" means a commit that
7
+ # touched the file; the count is the number of such commits within the window.
8
+ #
9
+ # Decisions (v0.1):
10
+ # * Window: the last 12 months by default ({DEFAULT_SINCE}), configurable via
11
+ # +since+ (anything `git log --since` accepts, e.g. "2025-01-01"). All of
12
+ # history over-weights long-lived files, so we bound it.
13
+ # * Renames are NOT followed. `git log --follow` only works for a single
14
+ # pathspec, so whole-repo rename tracking is out of scope; a renamed file
15
+ # starts a fresh count under its new path.
16
+ # * Outside a git repository, churn is empty (every file scores 0).
17
+ #
18
+ # Paths are reported relative to the repository root, as git emits them.
19
+ module Churn
20
+ DEFAULT_SINCE = "12 months ago"
21
+
22
+ module_function
23
+
24
+ # @param root [String] directory to run git in
25
+ # @param since [String] git --since boundary
26
+ # @return [Hash{String=>Integer}] path => commit count (default 0)
27
+ def collect(root:, since: DEFAULT_SINCE)
28
+ output = Git.log_name_only(root, since: since)
29
+ return empty_counts unless output
30
+
31
+ parse(output)
32
+ end
33
+
34
+ # Pure parser over `git log --name-only --pretty=format:` output. Counts how
35
+ # many lines (commits) mention each path.
36
+ # @param output [String]
37
+ # @return [Hash{String=>Integer}]
38
+ def parse(output)
39
+ counts = empty_counts
40
+ output.each_line(chomp: true) do |line|
41
+ next if line.empty?
42
+
43
+ counts[line] += 1
44
+ end
45
+ counts
46
+ end
47
+
48
+ def empty_counts
49
+ Hash.new(0)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "time"
5
+
6
+ module Moult
7
+ class CLI
8
+ # `moult boundaries [PATH]` — list recorded architecture-boundary violations from
9
+ # the project's packwerk artifacts, classified by severity. Thin layer: parse
10
+ # options, drive the library, hand the {BoundariesReport} to a formatter.
11
+ # Report-only: exit 0 on success (including when the project is not
12
+ # packwerk-configured), non-zero only on error.
13
+ class BoundariesCommand
14
+ # @return [Integer] process exit status
15
+ def run(argv)
16
+ options = parse(argv)
17
+ return puts_help(options) if options[:help]
18
+
19
+ root = File.expand_path(options[:path])
20
+ unless File.exist?(root)
21
+ warn "moult: no such file or directory: #{options[:path]}"
22
+ return 1
23
+ end
24
+
25
+ report = analyze(root, options)
26
+ puts render(report, options)
27
+ 0
28
+ rescue OptionParser::ParseError => e
29
+ warn "moult: #{e.message}"
30
+ 1
31
+ rescue => e
32
+ warn "moult: #{e.message}"
33
+ 1
34
+ end
35
+
36
+ private
37
+
38
+ def parse(argv)
39
+ options = {format: :table, min_severity: nil, quiet: false}
40
+ @parser = OptionParser.new do |o|
41
+ o.banner = "Usage: moult boundaries [PATH] [options]"
42
+ o.separator ""
43
+ o.separator "Options:"
44
+ o.on("--format FORMAT", [:table, :json], "Output format: table (default) or json") { |v| options[:format] = v }
45
+ o.on("--min-severity SEV", Boundaries::Severity::SCALE, "Hide findings below this severity: low, medium, high") { |v| options[:min_severity] = v }
46
+ o.on("--quiet", "Suppress informational notes on stderr") { options[:quiet] = true }
47
+ o.on("-h", "--help", "Show this message") { options[:help] = true }
48
+ end
49
+ @parser.permute!(argv)
50
+ options[:path] = argv.shift || "."
51
+ options
52
+ end
53
+
54
+ def puts_help(_options)
55
+ puts @parser
56
+ 0
57
+ end
58
+
59
+ def analyze(root, options)
60
+ root_dir = File.directory?(root) ? root : File.dirname(root)
61
+ report = Boundaries.build_report(
62
+ root: root_dir,
63
+ min_severity: options[:min_severity],
64
+ git_ref: Git.head_ref(root_dir),
65
+ generated_at: Time.now.utc.iso8601
66
+ )
67
+ note(options, report.configured ? "read packwerk artifacts: #{report.summary[:findings]} violation groups." : "no packwerk.yml found; not a packwerk project.")
68
+ report
69
+ end
70
+
71
+ def render(report, options)
72
+ case options[:format]
73
+ when :json then Formatters::BoundariesJson.render(report)
74
+ else Formatters::BoundariesTable.render(report)
75
+ end
76
+ end
77
+
78
+ def note(options, message)
79
+ warn "moult: #{message}" unless options[:quiet]
80
+ end
81
+ end
82
+ end
83
+ end