breadkit-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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +54 -0
  5. data/SECURITY.md +5 -0
  6. data/config/default.yml +36 -0
  7. data/docs/rules/Electrical/DanglingWire.md +9 -0
  8. data/docs/rules/Electrical/FloatingPin.md +9 -0
  9. data/docs/rules/Electrical/MissingSeriesResistor.md +11 -0
  10. data/docs/rules/Electrical/NetLabelConflict.md +10 -0
  11. data/docs/rules/Electrical/NoCommonGround.md +10 -0
  12. data/docs/rules/Electrical/PowerPinUnconnected.md +9 -0
  13. data/docs/rules/Electrical/ReversePolarity.md +10 -0
  14. data/docs/rules/Electrical/ShortCircuit.md +9 -0
  15. data/docs/rules/Electrical/ShortedComponent.md +9 -0
  16. data/docs/rules/Electrical/SplitRail.md +10 -0
  17. data/docs/rules/Electrical/SupplyVoltageRange.md +10 -0
  18. data/docs/rules/Intent/ConnectionMismatch.md +9 -0
  19. data/docs/rules/Intent/UnknownNet.md +9 -0
  20. data/docs/rules/Layout/DuplicateRef.md +10 -0
  21. data/docs/rules/Layout/HoleConflict.md +10 -0
  22. data/docs/rules/Layout/InvalidHole.md +9 -0
  23. data/docs/rules/Layout/InvalidPlacement.md +9 -0
  24. data/docs/rules/Layout/NoFreeHole.md +7 -0
  25. data/docs/rules/Layout/PinsInSameStrip.md +9 -0
  26. data/docs/rules/Layout/SplitNetLabel.md +3 -0
  27. data/docs/rules/Layout/UnknownBoard.md +3 -0
  28. data/docs/rules/Layout/UnknownOption.md +5 -0
  29. data/docs/rules/Layout/UnknownPart.md +9 -0
  30. data/docs/rules/Layout/UnknownPin.md +9 -0
  31. data/docs/rules/Layout/UnknownTransistorModel.md +5 -0
  32. data/docs/rules/Layout/UnplacedPin.md +3 -0
  33. data/docs/rules/Style/WireColor.md +9 -0
  34. data/exe/bklint +5 -0
  35. data/lib/breadkit/lint/rules/electrical.rb +371 -0
  36. data/lib/breadkit/lint/rules/intent.rb +59 -0
  37. data/lib/breadkit/lint/rules/layout.rb +33 -0
  38. data/lib/breadkit/lint/rules/style.rb +43 -0
  39. data/lib/breadkit/lint/rules/support.rb +88 -0
  40. data/lib/breadkit/lint/rules.rb +205 -0
  41. data/lib/breadkit/lint/version.rb +7 -0
  42. data/lib/breadkit/lint.rb +476 -0
  43. data/locales/en.yml +28 -0
  44. data/locales/ja.yml +65 -0
  45. data/package-lock.json +1172 -0
  46. data/package.json +12 -0
  47. data/scripts/check_bad_examples.rb +37 -0
  48. data/sig/breadkit/lint.rbs +68 -0
  49. data/site/favicon.svg +7 -0
  50. data/site/index.html +95 -0
  51. data/site/styles.css +14 -0
  52. metadata +111 -0
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/support"
4
+ require_relative "rules/layout"
5
+ require_relative "rules/electrical"
6
+ require_relative "rules/intent"
7
+ require_relative "rules/style"
8
+
9
+ module Breadkit
10
+ module Lint
11
+ class BuiltinRule < Rule
12
+ private
13
+
14
+ def emit(context, checker)
15
+ context.offenses.concat(context.checks.public_send(checker, context.circuit, self.class, context.state))
16
+ end
17
+
18
+ def emit_diagnostics(context)
19
+ emit(context, :diagnostics)
20
+ end
21
+ end
22
+
23
+ class DiagnosticRule < BuiltinRule
24
+ end
25
+
26
+ module Rules
27
+ module Layout
28
+ class InvalidHole < DiagnosticRule
29
+ rule "Layout/InvalidHole", severity: :error, description: "Unknown board hole"
30
+
31
+ def check(context) = emit_diagnostics(context)
32
+ end
33
+
34
+ class UnknownBoard < DiagnosticRule
35
+ rule "Layout/UnknownBoard", severity: :error, description: "Unknown board definition"
36
+
37
+ def check(context) = emit_diagnostics(context)
38
+ end
39
+
40
+ class UnknownPart < DiagnosticRule
41
+ rule "Layout/UnknownPart", severity: :error, description: "Unknown part definition"
42
+
43
+ def check(context) = emit_diagnostics(context)
44
+ end
45
+
46
+ class UnknownTransistorModel < DiagnosticRule
47
+ rule "Layout/UnknownTransistorModel", severity: :warning, description: "Unknown transistor model uses a generic pinout"
48
+
49
+ def check(context) = emit_diagnostics(context)
50
+ end
51
+
52
+ class UnknownPin < DiagnosticRule
53
+ rule "Layout/UnknownPin", severity: :error, description: "Unknown pin reference"
54
+
55
+ def check(context) = emit_diagnostics(context)
56
+ end
57
+
58
+ class UnknownOption < DiagnosticRule
59
+ rule "Layout/UnknownOption", severity: :error, description: "Unknown component option"
60
+
61
+ def check(context) = emit_diagnostics(context)
62
+ end
63
+
64
+ class UnplacedPin < DiagnosticRule
65
+ rule "Layout/UnplacedPin", severity: :error, description: "A component pin has no board hole"
66
+
67
+ def check(context) = emit_diagnostics(context)
68
+ end
69
+
70
+ class DuplicateRef < DiagnosticRule
71
+ rule "Layout/DuplicateRef", severity: :error, description: "Duplicate component or wire reference"
72
+
73
+ def check(context) = emit_diagnostics(context)
74
+ end
75
+
76
+ class InvalidPlacement < DiagnosticRule
77
+ rule "Layout/InvalidPlacement", severity: :error, description: "Invalid component placement"
78
+
79
+ def check(context) = emit_diagnostics(context)
80
+ end
81
+
82
+ class HoleConflict < DiagnosticRule
83
+ rule "Layout/HoleConflict", severity: :error, description: "Multiple leads occupy one hole"
84
+
85
+ def check(context) = emit_diagnostics(context)
86
+ end
87
+
88
+ class NoFreeHole < DiagnosticRule
89
+ rule "Layout/NoFreeHole", severity: :error, description: "No free hole is available"
90
+
91
+ def check(context) = emit_diagnostics(context)
92
+ end
93
+
94
+ class SplitNetLabel < DiagnosticRule
95
+ rule "Layout/SplitNetLabel", severity: :error, description: "A label names disconnected nets"
96
+
97
+ def check(context) = emit_diagnostics(context)
98
+ end
99
+
100
+ class PinsInSameStrip < BuiltinRule
101
+ rule "Layout/PinsInSameStrip", severity: :error, description: "Two component pins share one conductive strip"
102
+
103
+ def check(context) = emit(context, :same_strip)
104
+ end
105
+
106
+ end
107
+
108
+ module Electrical
109
+ class ShortCircuit < BuiltinRule
110
+ rule "Electrical/ShortCircuit", severity: :error, description: "Power constraints conflict", state_sensitive: true
111
+
112
+ def check(context) = emit(context, :short_circuit)
113
+ end
114
+
115
+ class ShortedComponent < BuiltinRule
116
+ rule "Electrical/ShortedComponent", severity: :warning, description: "A two-pin part is bypassed"
117
+
118
+ def check(context) = emit(context, :shorted_component)
119
+ end
120
+
121
+ class FloatingPin < BuiltinRule
122
+ rule "Electrical/FloatingPin", severity: :warning, description: "A component pin has no external connection"
123
+
124
+ def check(context) = emit(context, :floating_pins)
125
+ end
126
+
127
+ class DanglingWire < BuiltinRule
128
+ rule "Electrical/DanglingWire", severity: :warning, description: "A wire end has no other connection"
129
+
130
+ def check(context) = emit(context, :dangling_wires)
131
+ end
132
+
133
+ class SplitRail < BuiltinRule
134
+ rule "Electrical/SplitRail", severity: :warning, description: "A used split rail segment has no supply"
135
+
136
+ def check(context) = emit(context, :split_rails)
137
+ end
138
+
139
+ class MissingSeriesResistor < BuiltinRule
140
+ rule "Electrical/MissingSeriesResistor", severity: :error, description: "An LED has an unprotected path across a supply", state_sensitive: true
141
+
142
+ def check(context) = emit(context, :missing_series_resistors)
143
+ end
144
+
145
+ class ReversePolarity < BuiltinRule
146
+ rule "Electrical/ReversePolarity", severity: :error, description: "A polarized part is connected backwards", state_sensitive: true
147
+
148
+ def check(context) = emit(context, :reverse_polarity)
149
+ end
150
+
151
+ class PowerPinUnconnected < BuiltinRule
152
+ rule "Electrical/PowerPinUnconnected", severity: :warning, description: "An IC power or ground pin is not connected to a supply"
153
+
154
+ def check(context) = emit(context, :power_pins)
155
+ end
156
+
157
+ class SupplyVoltageRange < BuiltinRule
158
+ rule "Electrical/SupplyVoltageRange", severity: :error, description: "An IC supply voltage is outside its rated range", state_sensitive: true
159
+
160
+ def check(context) = emit(context, :supply_ranges)
161
+ end
162
+
163
+ class NoCommonGround < BuiltinRule
164
+ rule "Electrical/NoCommonGround", severity: :warning, description: "Power supplies do not share a ground"
165
+
166
+ def check(context) = emit(context, :common_ground)
167
+ end
168
+
169
+ class NetLabelConflict < BuiltinRule
170
+ rule "Electrical/NetLabelConflict", severity: :error, description: "Different labels name one net"
171
+
172
+ def check(context) = emit(context, :label_conflicts)
173
+ end
174
+
175
+ end
176
+
177
+ module Intent
178
+ class ConnectionMismatch < BuiltinRule
179
+ rule "Intent/ConnectionMismatch", severity: :error, description: "Wiring differs from declared expectations"
180
+
181
+ def check(context) = emit(context, :expectations)
182
+ end
183
+
184
+ class UnknownNet < BuiltinRule
185
+ rule "Intent/UnknownNet", severity: :error, description: "An expectation refers to an unknown net"
186
+
187
+ def check(context)
188
+ emit_diagnostics(context)
189
+ emit(context, :expectations)
190
+ end
191
+ end
192
+
193
+ end
194
+
195
+ module Style
196
+ class WireColor < BuiltinRule
197
+ rule "Style/WireColor", severity: :info, description: "A wire color differs from the supply color convention"
198
+
199
+ def check(context) = emit(context, :wire_colors)
200
+ end
201
+
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Lint
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,476 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "breadkit"
4
+ require "json"
5
+ require "yaml"
6
+ require "optparse"
7
+ require "pathname"
8
+ require "uri"
9
+ require "find"
10
+ require_relative "lint/version"
11
+
12
+ module Breadkit
13
+ module Lint
14
+ class Error < StandardError; end
15
+
16
+ Offense = Struct.new(:rule, :severity, :message, :location, :targets, :state, keyword_init: true)
17
+
18
+ class Rule
19
+ attr_reader :id, :severity, :description, :state_sensitive, :checker
20
+
21
+ def initialize(config = nil, id: nil, severity: nil, description: nil, state_sensitive: false, checker: nil)
22
+ @config, @id, @severity, @description = config, id || self.class.id, severity || self.class.severity, description || self.class.description
23
+ @state_sensitive, @checker = id ? state_sensitive : self.class.state_sensitive, checker
24
+ end
25
+
26
+ def self.rule(id, severity:, description:, state_sensitive: false)
27
+ @id, @severity, @description, @state_sensitive = id, severity.to_s, description, state_sensitive
28
+ Registry.register(self)
29
+ end
30
+
31
+ def self.id
32
+ @id
33
+ end
34
+
35
+ def self.severity
36
+ @severity
37
+ end
38
+
39
+ def self.description
40
+ @description
41
+ end
42
+
43
+ def self.state_sensitive
44
+ @state_sensitive
45
+ end
46
+
47
+ def check(_context)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ private
52
+
53
+ def add_offense(context, message, location:, targets: {})
54
+ context.offenses << Offense.new(rule: self.class.id, severity: context.config.severity(self.class), message: message,
55
+ location: location, targets: targets, state: context.state.name)
56
+ end
57
+ end
58
+
59
+ class Context
60
+ attr_reader :circuit, :state, :config, :checks, :offenses
61
+
62
+ def initialize(circuit, state, config, checks = nil)
63
+ @circuit, @state, @config, @checks, @offenses = circuit, state, config, checks, []
64
+ end
65
+ end
66
+
67
+ class Registry
68
+ @rules = []
69
+
70
+ def self.all
71
+ @rules
72
+ end
73
+
74
+ def self.register(rule)
75
+ raise Error, "duplicate rule #{rule.id}" if @rules.any? { |item| item.id == rule.id }
76
+ @rules << rule
77
+ end
78
+ end
79
+
80
+ class Config
81
+ DEFAULT_PATH = File.expand_path("../../config/default.yml", __dir__)
82
+
83
+ attr_reader :data
84
+
85
+ def initialize(path = nil)
86
+ @data = YAML.safe_load(File.read(DEFAULT_PATH, encoding: "UTF-8"), aliases: false) || {}
87
+ @config_dir = path ? File.dirname(File.expand_path(path)) : Dir.pwd
88
+ raise Error, "config not found: #{path}" if path && !File.file?(path)
89
+ merge_file(path) if path
90
+ Array(data["require"]).each { |file| require File.expand_path(file, @config_dir) }
91
+ validate!
92
+ end
93
+
94
+ def severity(rule)
95
+ value = data.dig(rule.id, "Severity") || rule.severity
96
+ value.to_s.downcase
97
+ end
98
+
99
+ def enabled?(rule)
100
+ enabled = data.dig(rule.id, "Enabled")
101
+ return data.dig("AllRules", "NewRules") == "enable" if enabled.to_s == "pending"
102
+ enabled != false
103
+ end
104
+
105
+ def switch_states
106
+ data.dig("AllRules", "SwitchStates") || "single"
107
+ end
108
+
109
+ def fail_level
110
+ data.dig("AllRules", "FailLevel") || "warning"
111
+ end
112
+
113
+ def excludes
114
+ Array(data.dig("AllRules", "Exclude"))
115
+ end
116
+
117
+ def extra_parts
118
+ Array(data["use_parts"]).flat_map { |pattern| Dir.glob(File.expand_path(pattern, @config_dir)) }
119
+ end
120
+
121
+ def unknown_rules
122
+ reserved = %w[AllRules inherit_from require use_parts]
123
+ (data.keys - reserved - Registry.all.map(&:id))
124
+ end
125
+
126
+ def excluded?(path)
127
+ absolute = File.expand_path(path)
128
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(@config_dir)).to_s
129
+ excludes.any? do |pattern|
130
+ File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
131
+ File.fnmatch?(pattern, absolute, File::FNM_PATHNAME | File::FNM_EXTGLOB)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def merge_file(path)
138
+ @data = deep_merge(@data, load_config(path, []))
139
+ rescue Psych::Exception => e
140
+ raise Error, "invalid config #{path}: #{e.message}"
141
+ end
142
+
143
+ def load_config(path, stack)
144
+ absolute = File.expand_path(path)
145
+ raise Error, "cyclic config inheritance: #{(stack + [absolute]).join(' -> ')}" if stack.include?(absolute)
146
+ override = YAML.safe_load(File.read(absolute, encoding: "UTF-8"), aliases: false) || {}
147
+ parents = Array(override.delete("inherit_from")).reduce({}) do |merged, parent|
148
+ parent_path = File.expand_path(parent, File.dirname(absolute))
149
+ deep_merge(merged, load_config(parent_path, stack + [absolute]))
150
+ end
151
+ deep_merge(parents, override)
152
+ end
153
+
154
+ def deep_merge(left, right)
155
+ left.merge(right) do |_key, current, replacement|
156
+ current.is_a?(Hash) && replacement.is_a?(Hash) ? deep_merge(current, replacement) : replacement
157
+ end
158
+ end
159
+
160
+ def validate!
161
+ fail_level = data.dig("AllRules", "FailLevel")
162
+ switch_states = data.dig("AllRules", "SwitchStates")
163
+ new_rules = data.dig("AllRules", "NewRules")
164
+ raise Error, "invalid fail level: #{fail_level}" if fail_level && !%w[error warning info].include?(fail_level.to_s)
165
+ raise Error, "invalid switch state mode: #{switch_states}" if switch_states && !%w[none single all].include?(switch_states.to_s)
166
+ raise Error, "invalid new rules mode: #{new_rules}" if new_rules && !%w[pending enable disable].include?(new_rules.to_s)
167
+ Registry.all.each do |rule|
168
+ severity_value = data.dig(rule.id, "Severity")
169
+ next unless severity_value && !%w[error warning info].include?(severity_value.to_s.downcase)
170
+ raise Error, "invalid severity for #{rule.id}: #{severity_value}"
171
+ end
172
+ end
173
+ end
174
+
175
+ require_relative "lint/rules"
176
+ Registry.const_set(:RULES, Registry.all.dup.freeze)
177
+
178
+ class Engine
179
+ BLOCKING_DIAGNOSTICS = %w[invalid_hole unknown_board unknown_part unknown_pin unknown_option unplaced_pin invalid_placement no_free_hole].freeze
180
+ LEVELS = { "info" => 0, "warning" => 1, "error" => 2 }.freeze
181
+
182
+ def initialize(config: Config.new, locale: "en")
183
+ @config = config
184
+ path = File.expand_path("../../locales/#{locale}.yml", __dir__)
185
+ messages = YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false)&.fetch("messages", {}) || {}
186
+ @checks = Checks.new(@config, messages)
187
+ end
188
+
189
+ def run(paths, only: nil, except: nil)
190
+ Array(paths).filter_map do |path|
191
+ next if excluded?(path)
192
+ begin
193
+ circuit = if path.end_with?(".json")
194
+ Breadkit.load(path)
195
+ else
196
+ document = Breadkit::DSL.load_file(path)
197
+ document.part_paths.concat(@config.extra_parts)
198
+ Breadkit::Resolver.new.call(document)
199
+ end
200
+ offenses = inspect_circuit(circuit, only, except)
201
+ { path: path, offenses: @checks.suppress(offenses, circuit.lint_disables, circuit),
202
+ skipped: circuit.diagnostics.any? { |item| BLOCKING_DIAGNOSTICS.include?(item.code) } }
203
+ rescue StandardError => e
204
+ offense = Offense.new(rule: "Fatal/EvaluationError", severity: "error", message: e.message,
205
+ location: Breadkit::SourceLocation.new(path: path, line: nil), targets: {}, state: nil)
206
+ { path: path, offenses: [offense] }
207
+ end
208
+ end
209
+ end
210
+
211
+ def fail?(files, threshold = @config.fail_level)
212
+ minimum = LEVELS.fetch(threshold.to_s, 1)
213
+ files.any? { |file| file[:offenses].any? { |item| LEVELS.fetch(item.severity, 2) >= minimum } }
214
+ end
215
+
216
+ def fatal?(files)
217
+ files.any? { |file| file[:offenses].any? { |item| item.rule.start_with?("Fatal/") } }
218
+ end
219
+
220
+ private
221
+
222
+ def inspect_circuit(circuit, only, except)
223
+ offenses = []
224
+ broken_layout = circuit.diagnostics.any? { |item| BLOCKING_DIAGNOSTICS.include?(item.code) }
225
+ states = circuit.states(@config.switch_states)
226
+ Registry.all.each do |rule|
227
+ next unless @config.enabled?(rule) && selected?(rule.id, only, except)
228
+ next if broken_layout && rule.id.start_with?("Electrical/", "Intent/")
229
+ relevant_states = rule.state_sensitive ? states : [states.first]
230
+ relevant_states.each do |state|
231
+ context = Context.new(circuit, state, @config, @checks)
232
+ checked = begin
233
+ rule.new(@config).check(context)
234
+ context.offenses
235
+ rescue StandardError, NotImplementedError => e
236
+ [@checks.offense("Fatal/RuleError",
237
+ @checks.translate("rule_error", "#{rule.id} failed: #{e.message}", rule: rule.id, error: e.message),
238
+ nil)]
239
+ end
240
+ checked.each { |item| item.state = nil if state.closed_switches.empty? }
241
+ offenses.concat(checked)
242
+ end
243
+ end
244
+ baseline = offenses.select { |item| item.state.nil? }.map { |item| [item.rule, item.message, item.location&.line] }
245
+ offenses.reject { |item| item.state && baseline.include?([item.rule, item.message, item.location&.line]) }
246
+ .uniq { |item| [item.rule, item.message, item.location&.line, item.state] }
247
+ .sort_by { |item| [item.location&.path.to_s, item.location&.line.to_i, item.rule] }
248
+ end
249
+
250
+ def selected?(id, only, except)
251
+ return false if only && !only.include?(id)
252
+ return false if except && except.include?(id)
253
+ true
254
+ end
255
+
256
+ def excluded?(path)
257
+ @config.excluded?(path)
258
+ end
259
+ end
260
+
261
+ class Formatter
262
+ def text(files, locale: "en")
263
+ lines = files.flat_map do |file|
264
+ entries = file[:offenses].map do |item|
265
+ level = { "error" => "E", "warning" => "W", "info" => "I" }.fetch(item.severity, "E")
266
+ state = item.state ? (locale == "ja" ? " (#{item.state} の状態)" : " (#{item.state} state)") : ""
267
+ path = item.location&.path || file[:path]
268
+ line = item.location&.line ? ":#{item.location.line}" : ""
269
+ "#{path}#{line}: #{level}: [#{item.rule}] #{item.message}#{state}"
270
+ end
271
+ entries << (locale == "ja" ? "#{file[:path]}: 配置エラーのため電気・意図の検査を省略しました" :
272
+ "#{file[:path]}: electrical and intent checks skipped because of layout errors") if file[:skipped]
273
+ entries
274
+ end
275
+ errors, warnings, infos = files.flat_map { |file| file[:offenses] }.group_by(&:severity).values_at("error", "warning", "info").map { |items| items ? items.length : 0 }
276
+ summary = if locale == "ja"
277
+ "#{files.length} ファイルを検査、#{errors + warnings + infos} 件の指摘(エラー #{errors} 件、警告 #{warnings} 件、情報 #{infos} 件)"
278
+ else
279
+ count = errors + warnings + infos
280
+ "#{files.length} #{files.length == 1 ? 'file' : 'files'} inspected, #{count} #{count == 1 ? 'offense' : 'offenses'} (#{errors} #{errors == 1 ? 'error' : 'errors'}, #{warnings} #{warnings == 1 ? 'warning' : 'warnings'}, #{infos} #{infos == 1 ? 'info' : 'infos'})"
281
+ end
282
+ (lines + [summary]).join("\n")
283
+ end
284
+
285
+ def json(files)
286
+ offenses = files.flat_map { |file| file[:offenses] }
287
+ JSON.pretty_generate(
288
+ schema_version: 1,
289
+ tool: { name: "bklint", version: VERSION },
290
+ files: files.map do |file|
291
+ { path: File.expand_path(file[:path]), analysis_skipped: !!file[:skipped], offenses: file[:offenses].map do |item|
292
+ { rule: item.rule, severity: item.severity, message: item.message,
293
+ location: { path: item.location&.path || file[:path], line: item.location&.line },
294
+ state: item.state, targets: item.targets }
295
+ end }
296
+ end,
297
+ summary: { files: files.length, errors: offenses.count { |item| item.severity == "error" },
298
+ warnings: offenses.count { |item| item.severity == "warning" }, infos: offenses.count { |item| item.severity == "info" } }
299
+ )
300
+ end
301
+
302
+ def github(files)
303
+ files.flat_map do |file|
304
+ file[:offenses].map do |item|
305
+ level = { "error" => "error", "warning" => "warning", "info" => "notice" }.fetch(item.severity, "error")
306
+ line = item.location&.line
307
+ message = escape_data(item.message)
308
+ location = "file=#{escape_property(item.location&.path || file[:path])}"
309
+ location += ",line=#{line}" if line
310
+ "::#{level} #{location},title=#{escape_property(item.rule)}::#{message}"
311
+ end
312
+ end.join("\n")
313
+ end
314
+
315
+ def sarif(files)
316
+ rules = (Registry.all.map do |rule|
317
+ level = { "error" => "error", "warning" => "warning", "info" => "note" }.fetch(rule.severity, "error")
318
+ definition = { id: rule.id, shortDescription: { text: rule.description }, defaultConfiguration: { level: level } }
319
+ path = File.expand_path("../../docs/rules/#{rule.id}.md", __dir__)
320
+ definition[:helpUri] = "https://github.com/breadkit/breadkit-lint/blob/main/docs/rules/#{rule.id}.md" if File.file?(path)
321
+ definition
322
+ end + %w[Fatal/EvaluationError Fatal/RuleError Config/InvalidDisable].map do |id|
323
+ { id: id, shortDescription: { text: id.split("/").last }, defaultConfiguration: { level: "error" } }
324
+ end)
325
+ results = files.flat_map do |file|
326
+ file[:offenses].map do |item|
327
+ result = { ruleId: item.rule, level: { "error" => "error", "warning" => "warning", "info" => "note" }.fetch(item.severity, "error"),
328
+ message: { text: item.message }, properties: { targets: item.targets, state: item.state } }
329
+ path = item.location&.path || file[:path]
330
+ relative = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(Dir.pwd)).to_s.tr("\\", "/")
331
+ uri = URI::DEFAULT_PARSER.escape(relative, /[^A-Za-z0-9\-._~\/]/)
332
+ physical = { artifactLocation: { uri: uri, uriBaseId: "%SRCROOT%" } }
333
+ physical[:region] = { startLine: item.location.line } if item.location&.line.to_i.positive?
334
+ result[:locations] = [{ physicalLocation: physical }]
335
+ result
336
+ end
337
+ end
338
+ root_path = URI::DEFAULT_PARSER.escape("#{File.expand_path(Dir.pwd)}/", /[^A-Za-z0-9\-._~\/]/)
339
+ root_uri = URI::File.build(path: root_path).to_s
340
+ JSON.pretty_generate(version: "2.1.0", "$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
341
+ runs: [{ tool: { driver: { name: "bklint", version: VERSION, rules: rules } },
342
+ originalUriBaseIds: { "%SRCROOT%" => { uri: root_uri } }, results: results }])
343
+ end
344
+
345
+ private
346
+
347
+ def escape_data(value)
348
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
349
+ end
350
+
351
+ def escape_property(value)
352
+ escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
353
+ end
354
+ end
355
+
356
+ class CLI
357
+ def run(argv)
358
+ options = { format: "text", fail_level: nil }
359
+ parser = OptionParser.new do |opts|
360
+ opts.banner = "Usage: bklint [options] [FILES...]"
361
+ opts.on("-f", "--format FORMAT", %w[text json github sarif]) { |value| options[:format] = value }
362
+ opts.on("-o", "--out PATH") { |value| options[:out] = value }
363
+ opts.on("-c", "--config PATH") { |value| options[:config] = value }
364
+ opts.on("--fail-level LEVEL", %w[error warning info]) { |value| options[:fail_level] = value }
365
+ opts.on("--only RULES") { |value| options[:only] = value.split(",") }
366
+ opts.on("--except RULES") { |value| options[:except] = value.split(",") }
367
+ opts.on("--switch-states MODE", %w[none single all]) { |value| options[:switch_states] = value }
368
+ opts.on("--list-rules") { options[:list_rules] = true }
369
+ opts.on("--explain RULE") { |value| options[:explain] = value }
370
+ opts.on("--locale LOCALE", %w[ja en]) { |value| options[:locale] = value }
371
+ opts.on("-v", "--version") { puts "bklint #{VERSION}"; return 0 }
372
+ opts.on("-h", "--help") { puts opts; return 0 }
373
+ end
374
+ parser.parse!(argv)
375
+ locale = options[:locale] || locale_from_environment
376
+ return list_rules(locale) if options[:list_rules]
377
+ return explain(options[:explain], locale) if options[:explain]
378
+ files = expand_inputs(argv)
379
+ configs = {}
380
+ results = files.flat_map do |path|
381
+ config_path = options[:config] || nearest_config(path)
382
+ unless configs.key?(config_path)
383
+ configs[config_path] = Config.new(config_path)
384
+ configs[config_path].unknown_rules.each do |rule|
385
+ suggestion = DidYouMean::SpellChecker.new(dictionary: Registry.all.map(&:id)).correct(rule).first
386
+ warn "bklint: unknown rule #{rule.inspect}#{suggestion ? "; did you mean #{suggestion.inspect}?" : ""}"
387
+ end
388
+ end
389
+ config = configs[config_path]
390
+ config.data["AllRules"] ||= {}
391
+ config.data["AllRules"]["SwitchStates"] = options[:switch_states] if options[:switch_states]
392
+ Engine.new(config: config, locale: locale).run([path], only: options[:only], except: options[:except])
393
+ end
394
+ formatter = Formatter.new
395
+ output = case options[:format]
396
+ when "json" then formatter.json(results)
397
+ when "github" then formatter.github(results)
398
+ when "sarif" then formatter.sarif(results)
399
+ else formatter.text(results, locale: locale)
400
+ end
401
+ options[:out] ? File.write(options[:out], output + "\n") : puts(output)
402
+ return 2 if results.any? { |file| file[:offenses].any? { |item| item.rule.start_with?("Fatal/") } }
403
+ results.any? do |file|
404
+ config = configs[options[:config] || nearest_config(file[:path])]
405
+ Engine.new(config: config).fail?([file], options[:fail_level] || config.fail_level)
406
+ end ? 1 : 0
407
+ rescue StandardError, ScriptError => e
408
+ warn "bklint: #{e.message}"
409
+ 2
410
+ end
411
+
412
+ private
413
+
414
+ def locale_from_environment
415
+ value = %w[LC_ALL LC_MESSAGES LANG].map { |key| ENV[key] }.find { |item| !item.to_s.empty? }
416
+ value.to_s.start_with?("ja") ? "ja" : "en"
417
+ end
418
+
419
+ def nearest_config(path)
420
+ directory = File.directory?(path) ? File.expand_path(path) : File.dirname(File.expand_path(path))
421
+ loop do
422
+ config = File.join(directory, ".bklint.yml")
423
+ return config if File.file?(config)
424
+ parent = File.dirname(directory)
425
+ return nil if parent == directory
426
+ directory = parent
427
+ end
428
+ end
429
+
430
+ def expand_inputs(paths)
431
+ roots = paths.empty? ? ["."] : paths
432
+ roots.flat_map do |root|
433
+ next [root] unless File.directory?(root)
434
+ found = []
435
+ Find.find(root) do |path|
436
+ if File.directory?(path)
437
+ Find.prune if path != root && (File.basename(path).start_with?(".") || File.basename(path) == "node_modules")
438
+ elsif path.end_with?(".bk.rb") || (path.end_with?(".json") && ir_json?(path))
439
+ found << path
440
+ end
441
+ end
442
+ found
443
+ end.flatten.uniq.sort
444
+ end
445
+
446
+ def ir_json?(path)
447
+ return true if path.end_with?(".bk.json", ".breadkit.json")
448
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
449
+ data.is_a?(Hash) && data["schema_version"] == 1
450
+ rescue JSON::ParserError, Encoding::InvalidByteSequenceError
451
+ false
452
+ end
453
+
454
+ def list_rules(locale)
455
+ Registry.all.each { |rule| puts "#{rule.id}\t#{rule.severity}\t#{localized_description(rule, locale)}" }
456
+ 0
457
+ end
458
+
459
+ def explain(id, locale)
460
+ rule = Registry.all.find { |item| item.id == id }
461
+ raise Error, "unknown rule #{id}" unless rule
462
+ path = File.expand_path("../../docs/rules/#{rule.id}.md", __dir__)
463
+ puts File.file?(path) ? File.read(path, encoding: "UTF-8") : "#{rule.id} (#{rule.severity})\n#{localized_description(rule, locale)}"
464
+ 0
465
+ end
466
+
467
+ def localized_description(rule, locale)
468
+ path = File.expand_path("../../locales/#{locale}.yml", __dir__)
469
+ translations = YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false) || {}
470
+ translations.dig("rules", rule.id) || rule.description
471
+ rescue Errno::ENOENT, Psych::Exception
472
+ rule.description
473
+ end
474
+ end
475
+ end
476
+ end