Module: Moult::Boundaries::Packwerk

Defined in:
lib/moult/boundaries/packwerk.rb

Overview

The architecture-boundary adapter — Moult's reader of Packwerk's on-disk artifacts and the only file that names Packwerk. Everything downstream consumes the Moult-owned Violation/Result value objects, never a packwerk type, so the backend is swappable (the "swap, not rewrite" invariant).

Like Coverage (which ingests SimpleCov/stdlib coverage files), this slice ingests packwerk's files rather than booting it: a live bin/packwerk check needs a bootable Rails/Zeitwerk app and emits only human prose / new-violation deltas, whereas packwerk serialises every recorded violation to stable, diffable package_todo.yml files. We read those (the package graph + the recorded violations packwerk already resolved via Zeitwerk) and own no part of the constant-resolution graph. Consequently Moult needs NO packwerk gem dependency (exactly as Coverage needs no simplecov). Live re-analysis — the fresh, line-level offense set — is deferred, the same way the Coverband and Flipper live stores are.

The package_todo.yml shape we parse (packwerk's own serialization):

<defining-package>:            # the package that OWNS the referenced constant
"::Some::Constant":          # the constant crossing the boundary
  violations:
  - dependency               # one or more violation types
  - privacy
  files:
  - path/to/referencing.rb   # the referencing files (root-relative)

The file lives at <referencing-package-dir>/package_todo.yml, so the referencing package is the file's directory (root-relative; "." for the root package). packwerk reports violations at FILE granularity (no line numbers), which fixes this slice's join at path level.

Defined Under Namespace

Classes: Result, Violation

Class Method Summary collapse

Class Method Details

.backend_versionObject

packwerk is not a Moult dependency, so its constant is normally absent; the version is recorded when it happens to be loaded, else nil (nullable in the contract). This is the only reference to the Packwerk constant in Moult.



109
110
111
# File 'lib/moult/boundaries/packwerk.rb', line 109

def backend_version
  defined?(::Packwerk::VERSION) ? ::Packwerk::VERSION : nil
end

.configured?(root) ⇒ Boolean

A packwerk.yml at the root is the unambiguous "this is a packwerk project" marker (it is required for any packwerk run).

Returns:

  • (Boolean)


64
65
66
# File 'lib/moult/boundaries/packwerk.rb', line 64

def configured?(root)
  File.exist?(File.join(root, "packwerk.yml"))
end

.detect(root:) ⇒ Result

Parameters:

  • root (String)

    absolute analysis root

Returns:



53
54
55
56
57
58
59
60
# File 'lib/moult/boundaries/packwerk.rb', line 53

def detect(root:)
  unless configured?(root)
    return Result.new(violations: [], backend: "packwerk", backend_version: backend_version, configured: false)
  end

  violations = todo_files(root).flat_map { |file| violations_in(file, root) }
  Result.new(violations: violations, backend: "packwerk", backend_version: backend_version, configured: true)
end

.package_name(dir, root) ⇒ Object

Root-relative package name; "." for the root package (packwerk's convention).



102
103
104
# File 'lib/moult/boundaries/packwerk.rb', line 102

def package_name(dir, root)
  SymbolId.relative_path(dir, root)
end

.todo_files(root) ⇒ Object



68
69
70
# File 'lib/moult/boundaries/packwerk.rb', line 68

def todo_files(root)
  Dir.glob(File.join(root, "**", "package_todo.yml")).sort
end

.violations_in(file, root) ⇒ Object

Parse one package_todo.yml into flat Violations. The referencing package is the file's directory (root-relative). A malformed/empty file is skipped rather than crashing the whole run.



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/moult/boundaries/packwerk.rb', line 75

def violations_in(file, root)
  referencing_package = package_name(File.dirname(file), root)
  data = YAML.safe_load_file(file)
  return [] unless data.is_a?(Hash)

  data.flat_map do |defining_package, constants|
    next [] unless constants.is_a?(Hash)
    constants.flat_map do |constant, detail|
      next [] unless detail.is_a?(Hash)
      types = Array(detail["violations"])
      paths = Array(detail["files"])
      types.product(paths).map do |type, path|
        Violation.new(
          violation_type: type.to_s,
          referencing_package: referencing_package,
          defining_package: defining_package.to_s,
          constant: constant.to_s,
          path: path.to_s
        )
      end
    end
  end
rescue Psych::Exception
  []
end