kamal-lint 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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/action.yml +61 -0
  6. data/bin/kamal-lint +7 -0
  7. data/lib/kamal/lint/check.rb +90 -0
  8. data/lib/kamal/lint/checks/accessory_image_latest.rb +43 -0
  9. data/lib/kamal/lint/checks/accessory_placement_missing.rb +49 -0
  10. data/lib/kamal/lint/checks/accessory_role_undefined.rb +43 -0
  11. data/lib/kamal/lint/checks/boot_limit_exceeds_hosts.rb +34 -0
  12. data/lib/kamal/lint/checks/builder_registry_secret_undeclared.rb +42 -0
  13. data/lib/kamal/lint/checks/empty_web_role.rb +37 -0
  14. data/lib/kamal/lint/checks/image_registry_mismatch.rb +39 -0
  15. data/lib/kamal/lint/checks/kamal_secrets_not_gitignored.rb +56 -0
  16. data/lib/kamal/lint/checks/missing_proxy_healthcheck.rb +27 -0
  17. data/lib/kamal/lint/checks/missing_service_name.rb +46 -0
  18. data/lib/kamal/lint/checks/registry_without_explicit_server.rb +37 -0
  19. data/lib/kamal/lint/checks/role_hosts_empty.rb +35 -0
  20. data/lib/kamal/lint/checks/secret_in_env_clear.rb +45 -0
  21. data/lib/kamal/lint/checks/secret_not_declared.rb +58 -0
  22. data/lib/kamal/lint/checks/ssl_without_host.rb +37 -0
  23. data/lib/kamal/lint/checks/traefik_legacy_keys.rb +58 -0
  24. data/lib/kamal/lint/cli.rb +109 -0
  25. data/lib/kamal/lint/finding.rb +32 -0
  26. data/lib/kamal/lint/formatters/github.rb +55 -0
  27. data/lib/kamal/lint/formatters/human.rb +118 -0
  28. data/lib/kamal/lint/formatters/json.rb +38 -0
  29. data/lib/kamal/lint/kamal_version.rb +62 -0
  30. data/lib/kamal/lint/loader.rb +175 -0
  31. data/lib/kamal/lint/registry.rb +32 -0
  32. data/lib/kamal/lint/runner.rb +102 -0
  33. data/lib/kamal/lint/secrets_file.rb +29 -0
  34. data/lib/kamal/lint/servers_helper.rb +60 -0
  35. data/lib/kamal/lint/version.rb +7 -0
  36. data/lib/kamal/lint.rb +63 -0
  37. metadata +177 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Formatters
6
+ class Human
7
+ COLORS = {
8
+ reset: "\e[0m",
9
+ bold: "\e[1m",
10
+ dim: "\e[2m",
11
+ red: "\e[31m",
12
+ yellow: "\e[33m",
13
+ green: "\e[32m",
14
+ blue: "\e[34m",
15
+ magenta: "\e[35m",
16
+ cyan: "\e[36m",
17
+ gray: "\e[90m"
18
+ }.freeze
19
+
20
+ SEVERITY_GLYPH = {
21
+ error: "✖",
22
+ warning: "⚠",
23
+ info: "•"
24
+ }.freeze
25
+
26
+ SEVERITY_COLOR = {
27
+ error: :red,
28
+ warning: :yellow,
29
+ info: :blue
30
+ }.freeze
31
+
32
+ def initialize(io: $stdout, color: nil)
33
+ @io = io
34
+ @color = color.nil? ? io.tty? : color
35
+ end
36
+
37
+ def render(result)
38
+ render_header(result)
39
+
40
+ if result.findings.empty?
41
+ @io.puts c(:green, " ✓ No issues found.")
42
+ return
43
+ end
44
+
45
+ result.findings
46
+ .sort_by { |f| [ SEVERITIES.index(f.severity), f.line || 0 ] }
47
+ .each { |finding| render_finding(finding) }
48
+
49
+ render_summary(result)
50
+ end
51
+
52
+ def render_fix_summary(result)
53
+ return if result.fixed.empty?
54
+
55
+ @io.puts
56
+ @io.puts c(:bold, "Applied autofixes:")
57
+ result.fixed.each do |finding|
58
+ @io.puts " #{c(:green, "✓")} [#{finding.check_id}] #{finding.message}"
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def render_header(result)
65
+ version = Kamal::Lint::VERSION
66
+ kamal = result.context.kamal_version || "?"
67
+ @io.puts "#{c(:bold, "kamal-lint")} #{c(:dim, version)} · kamal #{c(:cyan, kamal)} detected"
68
+ if (dest = result.context.destination)
69
+ @io.puts " destination: #{c(:cyan, dest)}"
70
+ end
71
+ @io.puts " config: #{c(:cyan, result.context.file_for_finding)}"
72
+ @io.puts
73
+ end
74
+
75
+ def render_finding(finding)
76
+ loc = "#{finding.file}:#{finding.line || "?"}"
77
+ glyph = SEVERITY_GLYPH[finding.severity] || "?"
78
+ color = SEVERITY_COLOR[finding.severity] || :gray
79
+ sev = finding.severity.to_s.ljust(7)
80
+ fix_hint = finding.autofixable? ? c(:dim, " (autofixable)") : ""
81
+
82
+ @io.puts "#{c(color, glyph)} #{c(:bold, sev)} #{c(:gray, loc)}#{fix_hint}"
83
+ @io.puts " #{finding.message}"
84
+ @io.puts " #{c(:dim, "[#{finding.check_id}]")}"
85
+ @io.puts
86
+ end
87
+
88
+ def render_summary(result)
89
+ errors = result.errors.size
90
+ warnings = result.warnings.size
91
+ infos = result.infos.size
92
+ autofixable = result.findings.count(&:autofixable?)
93
+
94
+ parts = []
95
+ parts << "#{c(:red, errors.to_s)} error#{plural(errors)}" if errors > 0
96
+ parts << "#{c(:yellow, warnings.to_s)} warning#{plural(warnings)}" if warnings > 0
97
+ parts << "#{c(:blue, infos.to_s)} info" if infos > 0
98
+ parts << "#{c(:dim, "#{autofixable} autofixable")}" if autofixable > 0
99
+
100
+ @io.puts c(:bold, "Summary: ") + parts.join(", ")
101
+ end
102
+
103
+ def plural(n)
104
+ n == 1 ? "" : "s"
105
+ end
106
+
107
+ def c(color, text)
108
+ return text unless @color
109
+
110
+ code = COLORS[color]
111
+ return text unless code
112
+
113
+ "#{code}#{text}#{COLORS[:reset]}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kamal
6
+ module Lint
7
+ module Formatters
8
+ class Json
9
+ def initialize(io: $stdout)
10
+ @io = io
11
+ end
12
+
13
+ def render(result)
14
+ payload = {
15
+ kamal_lint_version: Kamal::Lint::VERSION,
16
+ kamal_version: result.context.kamal_version,
17
+ file: result.context.file_for_finding,
18
+ destination: result.context.destination,
19
+ findings: result.findings.map(&:to_h),
20
+ summary: {
21
+ errors: result.errors.size,
22
+ warnings: result.warnings.size,
23
+ infos: result.infos.size,
24
+ autofixable: result.findings.count(&:autofixable?)
25
+ }
26
+ }
27
+ @io.puts JSON.pretty_generate(payload)
28
+ end
29
+
30
+ def render_fix_summary(result)
31
+ return if result.fixed.empty?
32
+
33
+ @io.puts JSON.pretty_generate(fixed: result.fixed.map(&:to_h))
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Kamal
6
+ module Lint
7
+ # Detect the installed Kamal version from (in priority order):
8
+ # 1. explicit override
9
+ # 2. Bundler.locked_gems
10
+ # 3. Gem.loaded_specs / Gem::Specification
11
+ # 4. shell out to `kamal version`
12
+ module KamalVersion
13
+ module_function
14
+
15
+ def detect(override: nil)
16
+ return normalize(override) if override
17
+
18
+ from_bundler || from_loaded_specs || from_shell
19
+ end
20
+
21
+ def from_bundler
22
+ return nil unless defined?(Bundler)
23
+
24
+ locked = Bundler.locked_gems&.specs&.find { |s| s.name == "kamal" }
25
+ locked&.version&.to_s
26
+ rescue Bundler::GemfileNotFound
27
+ nil
28
+ rescue => _e
29
+ nil
30
+ end
31
+
32
+ def from_loaded_specs
33
+ spec = Gem.loaded_specs["kamal"] if defined?(Gem) && Gem.respond_to?(:loaded_specs)
34
+ return spec.version.to_s if spec
35
+
36
+ if defined?(Gem::Specification)
37
+ found = Gem::Specification.find_all_by_name("kamal").max_by(&:version)
38
+ return found&.version&.to_s
39
+ end
40
+
41
+ nil
42
+ rescue => _e
43
+ nil
44
+ end
45
+
46
+ def from_shell
47
+ out, _err, status = Open3.capture3("kamal", "version")
48
+ return nil unless status.success?
49
+
50
+ out.strip.split.last
51
+ rescue Errno::ENOENT
52
+ nil
53
+ end
54
+
55
+ def normalize(value)
56
+ return nil if value.nil? || value.to_s.empty?
57
+
58
+ value.to_s.strip
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ module Kamal
8
+ module Lint
9
+ # Holds all the data a check needs to inspect a single Kamal config:
10
+ # the parsed YAML, the source lines, helper to look up source lines for a
11
+ # given path (for finding line numbers), the destination override, the
12
+ # secrets file contents, and a flag indicating whether Kamal's own loader
13
+ # rejected the config (in which case some checks are skipped to avoid
14
+ # cascading false positives).
15
+ Context = Struct.new(
16
+ :config_file,
17
+ :destination,
18
+ :working_dir,
19
+ :parsed,
20
+ :base_parsed,
21
+ :override_parsed,
22
+ :source_lines,
23
+ :line_index,
24
+ :secrets,
25
+ :secrets_path,
26
+ :gitignore_path,
27
+ :kamal_version,
28
+ :kamal_loaded,
29
+ :kamal_load_error,
30
+ keyword_init: true
31
+ ) do
32
+ def file_for_finding
33
+ return config_file unless destination
34
+
35
+ override_path = override_file
36
+ File.exist?(override_path) ? override_path : config_file
37
+ end
38
+
39
+ def override_file
40
+ File.join(File.dirname(config_file), "deploy.#{destination}.yml")
41
+ end
42
+
43
+ def line_for(path)
44
+ line_index[Array(path).join(".")]
45
+ end
46
+ end
47
+
48
+ module Loader
49
+ module_function
50
+
51
+ def load(config_file:, destination: nil, kamal_version: nil)
52
+ raise ConfigNotFoundError, "Config file not found: #{config_file}" unless File.exist?(config_file)
53
+
54
+ working_dir = Pathname.new(config_file).realpath.dirname.dirname.to_s
55
+ # If config_file is at config/deploy.yml inside a project, working_dir = project root.
56
+ # If the user pointed somewhere else, fall back to its parent dir's parent.
57
+
58
+ base_text = File.read(config_file)
59
+ base_parsed = safe_parse_yaml(base_text)
60
+ source_lines = base_text.lines
61
+
62
+ override_parsed = nil
63
+ if destination
64
+ override_path = File.join(File.dirname(config_file), "deploy.#{destination}.yml")
65
+ if File.exist?(override_path)
66
+ override_parsed = safe_parse_yaml(File.read(override_path))
67
+ source_lines = File.read(override_path).lines
68
+ end
69
+ end
70
+
71
+ merged = override_parsed ? deep_merge(base_parsed, override_parsed) : base_parsed
72
+
73
+ line_index = build_line_index(base_text)
74
+ secrets_path = File.join(working_dir, ".kamal", "secrets")
75
+ gitignore_path = File.join(working_dir, ".gitignore")
76
+ secrets_keys = SecretsFile.read_keys(secrets_path)
77
+
78
+ loaded = true
79
+ load_error = nil
80
+ begin
81
+ # Run Kamal's own loader for parse-level validation. We don't use the
82
+ # returned object — we keep working off the parsed Hash so we can
83
+ # report line numbers — but we surface Kamal's own errors as findings.
84
+ require "kamal"
85
+ Dir.chdir(working_dir) do
86
+ Kamal::Configuration.create_from(
87
+ config_file: Pathname.new(config_file),
88
+ destination: destination,
89
+ version: "kamal-lint"
90
+ )
91
+ end
92
+ rescue => e
93
+ loaded = false
94
+ load_error = e
95
+ end
96
+
97
+ Context.new(
98
+ config_file: config_file,
99
+ destination: destination,
100
+ working_dir: working_dir,
101
+ parsed: merged || {},
102
+ base_parsed: base_parsed || {},
103
+ override_parsed: override_parsed,
104
+ source_lines: source_lines,
105
+ line_index: line_index,
106
+ secrets: secrets_keys,
107
+ secrets_path: secrets_path,
108
+ gitignore_path: gitignore_path,
109
+ kamal_version: kamal_version || KamalVersion.detect,
110
+ kamal_loaded: loaded,
111
+ kamal_load_error: load_error
112
+ )
113
+ end
114
+
115
+ def safe_parse_yaml(text)
116
+ result = YAML.safe_load(text, aliases: true, permitted_classes: [ Symbol ])
117
+ result.is_a?(Hash) ? result : {}
118
+ rescue Psych::SyntaxError
119
+ {}
120
+ end
121
+
122
+ def deep_merge(base, override)
123
+ return override unless base.is_a?(Hash) && override.is_a?(Hash)
124
+
125
+ result = base.dup
126
+ override.each do |k, v|
127
+ result[k] = if result[k].is_a?(Hash) && v.is_a?(Hash)
128
+ deep_merge(result[k], v)
129
+ else
130
+ v
131
+ end
132
+ end
133
+ result
134
+ end
135
+
136
+ # Build a mapping from dot-path ("env.secret") to source line numbers.
137
+ # Walks the Psych AST.
138
+ def build_line_index(text)
139
+ index = {}
140
+ stream = Psych.parse_stream(text)
141
+ stream.children.each do |doc|
142
+ walk_node(doc.root, [], index)
143
+ end
144
+ index
145
+ rescue Psych::SyntaxError
146
+ index
147
+ end
148
+
149
+ def walk_node(node, path, index)
150
+ case node
151
+ when Psych::Nodes::Mapping
152
+ node.children.each_slice(2) do |key_node, value_node|
153
+ next unless key_node && value_node
154
+
155
+ key = key_node.value
156
+ new_path = path + [ key ]
157
+ index[new_path.join(".")] ||= key_node.start_line + 1
158
+ walk_node(value_node, new_path, index)
159
+ end
160
+ when Psych::Nodes::Sequence
161
+ node.children.each_with_index do |child, idx|
162
+ new_path = path + [ idx.to_s ]
163
+ # Index the position itself so checks can find sequence elements
164
+ # by ordinal (e.g. "env.secret.0").
165
+ index[new_path.join(".")] ||= child.start_line + 1
166
+ walk_node(child, new_path, index)
167
+ end
168
+ when Psych::Nodes::Scalar
169
+ # Scalars at non-root locations need no further indexing; their
170
+ # parent already wrote the line for them.
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ class Registry
6
+ def self.default
7
+ @default ||= new
8
+ end
9
+
10
+ def initialize
11
+ @checks = []
12
+ end
13
+
14
+ def register(check_class)
15
+ @checks << check_class unless @checks.include?(check_class)
16
+ check_class
17
+ end
18
+
19
+ def all
20
+ @checks.dup
21
+ end
22
+
23
+ def applicable_to(kamal_version)
24
+ @checks.select { |c| c.applies_to?(kamal_version) }
25
+ end
26
+
27
+ def find(id)
28
+ @checks.find { |c| c.id == id }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ Result = Struct.new(:findings, :context, :fixed, keyword_init: true) do
6
+ def errors
7
+ findings.select { |f| f.severity == :error }
8
+ end
9
+
10
+ def warnings
11
+ findings.select { |f| f.severity == :warning }
12
+ end
13
+
14
+ def infos
15
+ findings.select { |f| f.severity == :info }
16
+ end
17
+
18
+ def empty?
19
+ findings.empty?
20
+ end
21
+
22
+ def exit_code(fail_on: :error)
23
+ threshold = SEVERITIES.index(fail_on.to_sym)
24
+ worst = findings.map { |f| SEVERITIES.index(f.severity) }.compact.min
25
+ return 0 if worst.nil?
26
+
27
+ worst <= threshold ? 1 : 0
28
+ end
29
+ end
30
+
31
+ class Runner
32
+ def initialize(config_file:, destination: nil, kamal_version: nil,
33
+ registry: Lint.registry, fix: false, include_kamal_errors: false)
34
+ @config_file = config_file
35
+ @destination = destination
36
+ @kamal_version_override = kamal_version
37
+ @registry = registry
38
+ @fix = fix
39
+ @include_kamal_errors = include_kamal_errors
40
+ end
41
+
42
+ def call
43
+ context = load_context
44
+ findings = collect_findings(context)
45
+
46
+ fixed = @fix ? apply_autofixes(findings, context) : []
47
+
48
+ if @fix && !fixed.empty?
49
+ context = load_context
50
+ findings = collect_findings(context)
51
+ end
52
+
53
+ Result.new(findings: findings, context: context, fixed: fixed)
54
+ end
55
+
56
+ private
57
+
58
+ def load_context
59
+ Loader.load(
60
+ config_file: @config_file,
61
+ destination: @destination,
62
+ kamal_version: @kamal_version_override
63
+ )
64
+ end
65
+
66
+ def collect_findings(context)
67
+ findings = []
68
+
69
+ if @include_kamal_errors && context.kamal_load_error
70
+ findings << kamal_parse_error_finding(context)
71
+ end
72
+
73
+ @registry.applicable_to(context.kamal_version).each do |check_class|
74
+ findings.concat(Array(check_class.new(context).call))
75
+ end
76
+
77
+ findings
78
+ end
79
+
80
+ def kamal_parse_error_finding(context)
81
+ Finding.new(
82
+ check_id: "kamal-parse-error",
83
+ severity: :error,
84
+ message: "kamal could not load this config: #{context.kamal_load_error.message}",
85
+ file: context.file_for_finding,
86
+ line: 1,
87
+ column: 1,
88
+ autofix: nil
89
+ )
90
+ end
91
+
92
+ def apply_autofixes(findings, context)
93
+ applied = []
94
+ findings.select(&:autofixable?).each do |finding|
95
+ ok = finding.autofix.call(context)
96
+ applied << finding if ok
97
+ end
98
+ applied
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ # Reads .kamal/secrets (shell-style KEY=value, with optional `export` prefix
6
+ # and `#` comments). Returns the set of declared keys. We don't expand or
7
+ # substitute — we only care whether a name is declared.
8
+ module SecretsFile
9
+ module_function
10
+
11
+ def read_keys(path)
12
+ return [] unless path && File.exist?(path)
13
+
14
+ keys = []
15
+ File.foreach(path) do |raw|
16
+ line = raw.strip
17
+ next if line.empty?
18
+ next if line.start_with?("#")
19
+
20
+ line = line.sub(/\Aexport\s+/, "")
21
+ name, _eq, _value = line.partition("=")
22
+ name = name.strip
23
+ keys << name unless name.empty?
24
+ end
25
+ keys.uniq
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ # Helpers for walking `servers:` in its various shapes:
6
+ #
7
+ # servers: [host1, host2] → implicit "web" role
8
+ # servers: { web: [...], workers: [...] } → role => hosts
9
+ # servers: { web: { hosts: [...], cmd: ..., ... } } → role => expanded
10
+ module ServersHelper
11
+ module_function
12
+
13
+ def role_names(servers)
14
+ case servers
15
+ when Array
16
+ [ "web" ]
17
+ when Hash
18
+ servers.keys.map(&:to_s)
19
+ else
20
+ []
21
+ end
22
+ end
23
+
24
+ def hosts_for_role(servers, role)
25
+ case servers
26
+ when Array
27
+ role == "web" ? servers.dup : []
28
+ when Hash
29
+ entry = servers[role] || servers[role.to_sym]
30
+ extract_hosts(entry)
31
+ else
32
+ []
33
+ end
34
+ end
35
+
36
+ def all_hosts(servers)
37
+ case servers
38
+ when Array
39
+ servers.dup
40
+ when Hash
41
+ servers.values.flat_map { |v| extract_hosts(v) }
42
+ else
43
+ []
44
+ end
45
+ end
46
+
47
+ def extract_hosts(entry)
48
+ case entry
49
+ when Array
50
+ entry
51
+ when Hash
52
+ hosts = entry["hosts"] || entry[:hosts] || []
53
+ hosts.is_a?(Array) ? hosts : [ hosts ].compact
54
+ else
55
+ []
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/kamal/lint.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lint/version"
4
+
5
+ module Kamal
6
+ module Lint
7
+ SEVERITIES = %i[error warning info].freeze
8
+
9
+ class Error < StandardError; end
10
+
11
+ class ConfigNotFoundError < Error; end
12
+
13
+ def self.registry
14
+ Registry.default
15
+ end
16
+
17
+ def self.formatters
18
+ FORMATTERS
19
+ end
20
+ end
21
+ end
22
+
23
+ require_relative "lint/finding"
24
+ require_relative "lint/check"
25
+ require_relative "lint/registry"
26
+ require_relative "lint/secrets_file"
27
+ require_relative "lint/servers_helper"
28
+ require_relative "lint/loader"
29
+ require_relative "lint/kamal_version"
30
+ require_relative "lint/runner"
31
+
32
+ require_relative "lint/formatters/human"
33
+ require_relative "lint/formatters/json"
34
+ require_relative "lint/formatters/github"
35
+
36
+ module Kamal
37
+ module Lint
38
+ FORMATTERS = {
39
+ "human" => Formatters::Human,
40
+ "json" => Formatters::Json,
41
+ "github" => Formatters::Github
42
+ }.freeze
43
+ end
44
+ end
45
+
46
+ require_relative "lint/checks/secret_not_declared"
47
+ require_relative "lint/checks/accessory_role_undefined"
48
+ require_relative "lint/checks/role_hosts_empty"
49
+ require_relative "lint/checks/image_registry_mismatch"
50
+ require_relative "lint/checks/builder_registry_secret_undeclared"
51
+ require_relative "lint/checks/ssl_without_host"
52
+ require_relative "lint/checks/empty_web_role"
53
+ require_relative "lint/checks/traefik_legacy_keys"
54
+ require_relative "lint/checks/boot_limit_exceeds_hosts"
55
+ require_relative "lint/checks/accessory_placement_missing"
56
+ require_relative "lint/checks/missing_service_name"
57
+ require_relative "lint/checks/kamal_secrets_not_gitignored"
58
+ require_relative "lint/checks/secret_in_env_clear"
59
+ require_relative "lint/checks/missing_proxy_healthcheck"
60
+ require_relative "lint/checks/accessory_image_latest"
61
+ require_relative "lint/checks/registry_without_explicit_server"
62
+
63
+ require_relative "lint/cli"