breadkit-lint 0.1.0 → 0.2.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.yamllint +16 -0
  3. data/CHANGELOG.md +30 -2
  4. data/README.md +55 -40
  5. data/action.yml +42 -0
  6. data/config/default.yml +33 -0
  7. data/docs/REFERENCE.md +129 -0
  8. data/docs/rules/Electrical/CapacitorVoltageRating.md +5 -0
  9. data/docs/rules/Electrical/FloatingInput.md +11 -0
  10. data/docs/rules/Electrical/GpioOvercurrent.md +18 -0
  11. data/docs/rules/Electrical/I2CAddressConflict.md +5 -0
  12. data/docs/rules/Electrical/I2CPullupMissing.md +5 -0
  13. data/docs/rules/Electrical/LedOvercurrent.md +5 -0
  14. data/docs/rules/Electrical/MinimumResistance.md +14 -0
  15. data/docs/rules/Electrical/MissingBaseResistor.md +5 -0
  16. data/docs/rules/Electrical/MissingDecouplingCapacitor.md +5 -0
  17. data/docs/rules/Electrical/MissingFlybackDiode.md +18 -0
  18. data/docs/rules/Electrical/MissingPullResistor.md +17 -0
  19. data/docs/rules/Electrical/MissingSeriesResistor.md +3 -1
  20. data/docs/rules/Electrical/NoCommonGround.md +1 -0
  21. data/docs/rules/Electrical/RailPolarityMismatch.md +10 -0
  22. data/docs/rules/Electrical/ResistorPowerRating.md +5 -0
  23. data/docs/rules/Electrical/ReversePolarity.md +1 -1
  24. data/docs/rules/Electrical/SupplyOverload.md +11 -0
  25. data/docs/rules/Electrical/SupplyVoltageRange.md +1 -0
  26. data/docs/rules/Electrical/VoltageDomainMismatch.md +6 -0
  27. data/docs/rules/Intent/ConnectionMismatch.md +3 -1
  28. data/docs/rules/Intent/MeasurementUnavailable.md +10 -0
  29. data/docs/rules/Layout/AmbiguousSupplySource.md +5 -0
  30. data/docs/rules/Layout/BodyOverlap.md +11 -0
  31. data/docs/rules/Layout/HoleCovered.md +11 -0
  32. data/docs/rules/Layout/InvalidColor.md +3 -0
  33. data/docs/rules/Layout/InvalidOption.md +3 -0
  34. data/docs/rules/Layout/InvalidRoute.md +3 -0
  35. data/docs/rules/Layout/InvalidValue.md +3 -0
  36. data/docs/rules/Layout/InvalidWireId.md +3 -0
  37. data/docs/rules/Layout/LeadSpan.md +21 -0
  38. data/docs/rules/Layout/PartOverride.md +3 -0
  39. data/docs/rules/Layout/UnknownSupplySource.md +5 -0
  40. data/docs/rules/Layout/UnmatchedParts.md +3 -0
  41. data/docs/rules/Layout/WireOverIC.md +11 -0
  42. data/docs/rules/Lint/RedundantDisable.md +3 -0
  43. data/docs/rules/Lint/UnknownRuleInDisable.md +5 -0
  44. data/lib/breadkit/lint/baseline.rb +38 -0
  45. data/lib/breadkit/lint/rake_task.rb +22 -0
  46. data/lib/breadkit/lint/rules/electrical.rb +580 -30
  47. data/lib/breadkit/lint/rules/intent.rb +69 -4
  48. data/lib/breadkit/lint/rules/layout.rb +148 -0
  49. data/lib/breadkit/lint/rules/style.rb +36 -7
  50. data/lib/breadkit/lint/rules/support.rb +45 -11
  51. data/lib/breadkit/lint/rules.rb +205 -2
  52. data/lib/breadkit/lint/source_editor.rb +138 -0
  53. data/lib/breadkit/lint/version.rb +1 -1
  54. data/lib/breadkit/lint.rb +422 -59
  55. data/lib/guard/breadkit/templates/Guardfile +7 -0
  56. data/lib/guard/breadkit.rb +26 -0
  57. data/locales/en.yml +82 -0
  58. data/locales/ja.yml +130 -4
  59. data/locales/ko.yml +191 -0
  60. data/locales/zh.yml +191 -0
  61. data/package-lock.json +103 -0
  62. data/package.json +2 -1
  63. data/scripts/action-run.sh +51 -0
  64. data/scripts/build-rule-pages.mjs +87 -0
  65. data/scripts/check-rule-pages.mjs +18 -0
  66. data/scripts/pre-commit +24 -0
  67. data/sig/breadkit/lint.rbs +12 -1
  68. data/site/index.html +9 -6
  69. data/site/schemas/lint-v1.json +71 -0
  70. data/site/styles.css +13 -0
  71. metadata +64 -4
data/lib/breadkit/lint.rb CHANGED
@@ -7,13 +7,18 @@ require "optparse"
7
7
  require "pathname"
8
8
  require "uri"
9
9
  require "find"
10
+ require "open3"
11
+ require "tmpdir"
12
+ require "fileutils"
10
13
  require_relative "lint/version"
11
14
 
12
15
  module Breadkit
13
16
  module Lint
14
17
  class Error < StandardError; end
18
+ require_relative "lint/baseline"
15
19
 
16
- Offense = Struct.new(:rule, :severity, :message, :location, :targets, :state, keyword_init: true)
20
+ Offense = Struct.new(:rule, :severity, :message, :location, :targets, :state, :column, :suggestion, keyword_init: true)
21
+ require_relative "lint/source_editor"
17
22
 
18
23
  class Rule
19
24
  attr_reader :id, :severity, :description, :state_sensitive, :checker
@@ -106,6 +111,37 @@ module Breadkit
106
111
  data.dig("AllRules", "SwitchStates") || "single"
107
112
  end
108
113
 
114
+ def state_budget
115
+ data.dig("AllRules", "StateBudget")
116
+ end
117
+
118
+ def states(circuit, mode = switch_states)
119
+ return circuit.states(mode) unless state_budget && mode.to_s == "all"
120
+ return circuit.states(mode, budget: state_budget) if circuit.method(:states).parameters.any? { |kind, name| kind == :key && name == :budget }
121
+
122
+ count = circuit.states("single").length - 1
123
+ required = 1 << count
124
+ raise Error, "#{required} switch states exceed budget #{state_budget}" if required > state_budget
125
+ raise Error, "exhaustive state analysis above 8 switches requires a newer breadkit gem" if count > 8
126
+ circuit.states(mode)
127
+ end
128
+
129
+ def intent_states(circuit, expectations)
130
+ singles = circuit.states("single")
131
+ ordered = singles.drop(1).map(&:name)
132
+ by_name = singles.drop(1).to_h { |state| [state.name, state] }
133
+ composite = expectations.filter_map do |expectation|
134
+ name = expectation["when"] || expectation[:when]
135
+ next unless name&.include?(",")
136
+
137
+ refs = name.split(",", -1)
138
+ next unless refs.uniq == refs && refs == ordered.select { |ref| refs.include?(ref) }
139
+
140
+ Breadkit::State.new(name: name, closed_switches: refs.flat_map { |ref| by_name.fetch(ref).closed_switches })
141
+ end
142
+ singles + composite.uniq(&:name)
143
+ end
144
+
109
145
  def fail_level
110
146
  data.dig("AllRules", "FailLevel") || "warning"
111
147
  end
@@ -124,16 +160,26 @@ module Breadkit
124
160
  end
125
161
 
126
162
  def excluded?(path)
163
+ matches_path?(excludes, path)
164
+ end
165
+
166
+ def rule_applies?(rule, path)
167
+ include_patterns = data.dig(rule.id, "Include")
168
+ (include_patterns.nil? || matches_path?(Array(include_patterns), path)) &&
169
+ !matches_path?(Array(data.dig(rule.id, "Exclude")), path)
170
+ end
171
+
172
+ private
173
+
174
+ def matches_path?(patterns, path)
127
175
  absolute = File.expand_path(path)
128
176
  relative = Pathname.new(absolute).relative_path_from(Pathname.new(@config_dir)).to_s
129
- excludes.any? do |pattern|
177
+ patterns.any? do |pattern|
130
178
  File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
131
179
  File.fnmatch?(pattern, absolute, File::FNM_PATHNAME | File::FNM_EXTGLOB)
132
180
  end
133
181
  end
134
182
 
135
- private
136
-
137
183
  def merge_file(path)
138
184
  @data = deep_merge(@data, load_config(path, []))
139
185
  rescue Psych::Exception => e
@@ -161,10 +207,17 @@ module Breadkit
161
207
  fail_level = data.dig("AllRules", "FailLevel")
162
208
  switch_states = data.dig("AllRules", "SwitchStates")
163
209
  new_rules = data.dig("AllRules", "NewRules")
210
+ budget = data.dig("AllRules", "StateBudget")
164
211
  raise Error, "invalid fail level: #{fail_level}" if fail_level && !%w[error warning info].include?(fail_level.to_s)
165
212
  raise Error, "invalid switch state mode: #{switch_states}" if switch_states && !%w[none single all].include?(switch_states.to_s)
166
213
  raise Error, "invalid new rules mode: #{new_rules}" if new_rules && !%w[pending enable disable].include?(new_rules.to_s)
214
+ raise Error, "StateBudget must be a positive integer" if budget && (!budget.is_a?(Integer) || !budget.positive?)
167
215
  Registry.all.each do |rule|
216
+ %w[Include Exclude].each do |key|
217
+ patterns = data.dig(rule.id, key)
218
+ next if patterns.nil? || patterns.is_a?(String) || (patterns.is_a?(Array) && patterns.all? { |pattern| pattern.is_a?(String) })
219
+ raise Error, "invalid #{rule.id} #{key} patterns"
220
+ end
168
221
  severity_value = data.dig(rule.id, "Severity")
169
222
  next unless severity_value && !%w[error warning info].include?(severity_value.to_s.downcase)
170
223
  raise Error, "invalid severity for #{rule.id}: #{severity_value}"
@@ -176,7 +229,7 @@ module Breadkit
176
229
  Registry.const_set(:RULES, Registry.all.dup.freeze)
177
230
 
178
231
  class Engine
179
- BLOCKING_DIAGNOSTICS = %w[invalid_hole unknown_board unknown_part unknown_pin unknown_option unplaced_pin invalid_placement no_free_hole].freeze
232
+ BLOCKING_DIAGNOSTICS = %w[invalid_hole unknown_board unknown_part unknown_pin unknown_supply_source ambiguous_supply_source unknown_option invalid_option invalid_value invalid_color invalid_route invalid_wire_id unplaced_pin invalid_placement no_free_hole].freeze
180
233
  LEVELS = { "info" => 0, "warning" => 1, "error" => 2 }.freeze
181
234
 
182
235
  def initialize(config: Config.new, locale: "en")
@@ -186,23 +239,35 @@ module Breadkit
186
239
  @checks = Checks.new(@config, messages)
187
240
  end
188
241
 
189
- def run(paths, only: nil, except: nil)
242
+ def run(paths, only: nil, except: nil, timeout: 10, source: nil)
190
243
  Array(paths).filter_map do |path|
191
244
  next if excluded?(path)
192
245
  begin
193
246
  circuit = if path.end_with?(".json")
194
247
  Breadkit.load(path)
195
248
  else
196
- document = Breadkit::DSL.load_file(path)
249
+ document = if path.end_with?(".bk.yml", ".bk.yaml", ".bk.toml")
250
+ raise Error, "declarative YAML/TOML input requires a newer breadkit gem" unless defined?(Breadkit::StructuredInput)
251
+
252
+ source ? Breadkit::StructuredInput.load_source(path, source) : Breadkit::StructuredInput.load_file(path)
253
+ else
254
+ Breadkit::DSL.load_file(path, timeout: timeout, source: source)
255
+ end
197
256
  document.part_paths.concat(@config.extra_parts)
198
257
  Breadkit::Resolver.new.call(document)
199
258
  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
259
+ offenses = inspect_circuit(circuit, path, only, except)
260
+ skipped = circuit.diagnostics.any? { |item| BLOCKING_DIAGNOSTICS.include?(item.code) }
261
+ { path: path, offenses: @checks.suppress(offenses, circuit.lint_disables, circuit, path: path, only: only, except: except, skipped: skipped),
262
+ skipped: skipped }
263
+ rescue StandardError, ScriptError, SystemStackError => e
264
+ location = e.respond_to?(:location) && e.location
265
+ location ||= begin
266
+ line = e.message[/\A(?:#{Regexp.escape(path)}|#{Regexp.escape(File.expand_path(path))}):(\d+):/, 1]
267
+ Breadkit::SourceLocation.new(path: path, line: line&.to_i)
268
+ end
204
269
  offense = Offense.new(rule: "Fatal/EvaluationError", severity: "error", message: e.message,
205
- location: Breadkit::SourceLocation.new(path: path, line: nil), targets: {}, state: nil)
270
+ location: location, targets: {}, state: nil)
206
271
  { path: path, offenses: [offense] }
207
272
  end
208
273
  end
@@ -219,14 +284,20 @@ module Breadkit
219
284
 
220
285
  private
221
286
 
222
- def inspect_circuit(circuit, only, except)
287
+ def inspect_circuit(circuit, path, only, except)
223
288
  offenses = []
224
289
  broken_layout = circuit.diagnostics.any? { |item| BLOCKING_DIAGNOSTICS.include?(item.code) }
225
- states = circuit.states(@config.switch_states)
290
+ states = @config.states(circuit)
291
+ scoped_expectations = circuit.expectations.any? { |item| item["when"] || item[:when] }
292
+ intent_states = scoped_expectations ? @config.intent_states(circuit, circuit.expectations) : states
226
293
  Registry.all.each do |rule|
227
- next unless @config.enabled?(rule) && selected?(rule.id, only, except)
294
+ next unless @config.enabled?(rule) && @config.rule_applies?(rule, path) && selected?(rule.id, only, except)
228
295
  next if broken_layout && rule.id.start_with?("Electrical/", "Intent/")
229
- relevant_states = rule.state_sensitive ? states : [states.first]
296
+ relevant_states = if rule.id.start_with?("Intent/") && scoped_expectations
297
+ intent_states
298
+ else
299
+ rule.state_sensitive ? states : [states.first]
300
+ end
230
301
  relevant_states.each do |state|
231
302
  context = Context.new(circuit, state, @config, @checks)
232
303
  checked = begin
@@ -241,10 +312,12 @@ module Breadkit
241
312
  offenses.concat(checked)
242
313
  end
243
314
  end
315
+ pull_pins = offenses.select { |item| item.rule == "Electrical/MissingPullResistor" }.flat_map { |item| item.targets.fetch(:pins, []) }
316
+ offenses.reject! { |item| item.rule == "Electrical/FloatingInput" && item.state.nil? && (item.targets.fetch(:pins, []) & pull_pins).any? }
244
317
  baseline = offenses.select { |item| item.state.nil? }.map { |item| [item.rule, item.message, item.location&.line] }
245
318
  offenses.reject { |item| item.state && baseline.include?([item.rule, item.message, item.location&.line]) }
246
319
  .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] }
320
+ .sort_by { |item| [item.location&.path.to_s, item.location&.line.to_i, item.rule, item.message] }
248
321
  end
249
322
 
250
323
  def selected?(id, only, except)
@@ -259,21 +332,46 @@ module Breadkit
259
332
  end
260
333
 
261
334
  class Formatter
262
- def text(files, locale: "en")
335
+ REPORT_LABELS = {
336
+ "zh" => { state: " (%{state} 状态)", why: "原因", skipped: "%{path}: 因布局错误而跳过电气与意图检查",
337
+ summary: "检查了 %{files} 个文件,共 %{count} 条诊断(错误 %{errors}、警告 %{warnings}、信息 %{infos})" },
338
+ "ko" => { state: " (%{state} 상태)", why: "이유", skipped: "%{path}: 배치 오류로 전기 및 의도 검사 생략",
339
+ summary: "파일 %{files}개 검사, 진단 %{count}개 (오류 %{errors}, 경고 %{warnings}, 정보 %{infos})" }
340
+ }.freeze
341
+
342
+ def text(files, locale: "en", teach: false)
343
+ labels = REPORT_LABELS[locale]
263
344
  lines = files.flat_map do |file|
264
- entries = file[:offenses].map do |item|
345
+ entries = file[:offenses].flat_map do |item|
265
346
  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}"
347
+ state = if item.state
348
+ labels ? format(labels[:state], state: item.state) : (locale == "ja" ? " (#{item.state} の状態)" : " (#{item.state} state)")
349
+ else
350
+ ""
351
+ end
352
+ path = display_path(item.location&.path || file[:path])
353
+ line = item.location&.line ? ":#{item.location.line}#{item.column ? ":#{item.column}" : ""}" : ""
354
+ heading = "#{path}#{line}: #{level}: [#{item.rule}] #{item.message}#{state}"
355
+ guidance = teach && [teach_guidance(item.rule, locale), item.suggestion].compact.join(" ")
356
+ guidance = nil if guidance == ""
357
+ guidance ? [heading, " #{labels ? labels[:why] : (locale == 'ja' ? '説明' : 'Why')}: #{guidance}"] : [heading]
358
+ end
359
+ if file[:skipped]
360
+ entries << if labels
361
+ format(labels[:skipped], path: display_path(file[:path]))
362
+ elsif locale == "ja"
363
+ "#{display_path(file[:path])}: 配置エラーのため電気・意図の検査を省略しました"
364
+ else
365
+ "#{display_path(file[:path])}: electrical and intent checks skipped because of layout errors"
366
+ end
270
367
  end
271
- entries << (locale == "ja" ? "#{file[:path]}: 配置エラーのため電気・意図の検査を省略しました" :
272
- "#{file[:path]}: electrical and intent checks skipped because of layout errors") if file[:skipped]
273
368
  entries
274
369
  end
275
370
  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"
371
+ summary = if labels
372
+ format(labels[:summary], files: files.length, count: errors + warnings + infos,
373
+ errors: errors, warnings: warnings, infos: infos)
374
+ elsif locale == "ja"
277
375
  "#{files.length} ファイルを検査、#{errors + warnings + infos} 件の指摘(エラー #{errors} 件、警告 #{warnings} 件、情報 #{infos} 件)"
278
376
  else
279
377
  count = errors + warnings + infos
@@ -282,15 +380,17 @@ module Breadkit
282
380
  (lines + [summary]).join("\n")
283
381
  end
284
382
 
285
- def json(files)
383
+ def json(files, locale: "en")
286
384
  offenses = files.flat_map { |file| file[:offenses] }
287
385
  JSON.pretty_generate(
288
386
  schema_version: 1,
289
387
  tool: { name: "bklint", version: VERSION },
290
388
  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 },
389
+ { path: display_path(file[:path]), analysis_skipped: !!file[:skipped], offenses: file[:offenses].map do |item|
390
+ { rule: item.rule, severity: item.severity, message: item.message, docs_url: rule_help_url(item.rule),
391
+ suggestion: item.suggestion || suggestion_for_rule(item.rule, locale),
392
+ location: { path: display_path(item.location&.path || file[:path]), line: item.location&.line }
393
+ .merge(item.column ? { column: item.column } : {}),
294
394
  state: item.state, targets: item.targets }
295
395
  end }
296
396
  end,
@@ -305,19 +405,68 @@ module Breadkit
305
405
  level = { "error" => "error", "warning" => "warning", "info" => "notice" }.fetch(item.severity, "error")
306
406
  line = item.location&.line
307
407
  message = escape_data(item.message)
308
- location = "file=#{escape_property(item.location&.path || file[:path])}"
408
+ location = "file=#{escape_property(display_path(item.location&.path || file[:path]))}"
309
409
  location += ",line=#{line}" if line
410
+ location += ",col=#{item.column}" if line && item.column
310
411
  "::#{level} #{location},title=#{escape_property(item.rule)}::#{message}"
311
412
  end
312
413
  end.join("\n")
313
414
  end
314
415
 
315
- def sarif(files)
416
+ def markdown(files)
417
+ rows = files.flat_map do |file|
418
+ file[:offenses].map do |item|
419
+ path = display_path(item.location&.path || file[:path])
420
+ [path, item.location&.line, item.severity, item.rule, item.message].map do |value|
421
+ xml_escape(value).gsub("|", "\\|").gsub(/\r?\n/, "<br>")
422
+ end.join(" | ").then { |row| "| #{row} |" }
423
+ end
424
+ end
425
+ (["| File | Line | Severity | Rule | Message |", "| --- | ---: | --- | --- | --- |"] + rows).join("\n")
426
+ end
427
+
428
+ def junit(files)
429
+ failures = files.count { |file| file[:offenses].any? { |item| item.severity == "error" } }
430
+ cases = files.map do |file|
431
+ name = xml_escape(display_path(file[:path]))
432
+ errors, notices = file[:offenses].partition { |item| item.severity == "error" }
433
+ details = errors.map do |item|
434
+ "<failure type=\"#{xml_escape(item.rule)}\" message=\"#{xml_escape(item.message)}\"/>"
435
+ end
436
+ details << "<system-out>#{xml_escape(notices.map { |item| "#{item.severity}: #{item.rule}: #{item.message}" }.join("\n"))}</system-out>" unless notices.empty?
437
+ "<testcase name=\"#{name}\">#{details.join}</testcase>"
438
+ end
439
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?><testsuites><testsuite name=\"bklint\" tests=\"#{files.length}\" failures=\"#{failures}\">#{cases.join}</testsuite></testsuites>"
440
+ end
441
+
442
+ def checkstyle(files)
443
+ entries = files.map do |file|
444
+ errors = file[:offenses].map do |item|
445
+ line = item.location&.line ? " line=\"#{item.location.line}\"" : ""
446
+ "<error#{line} severity=\"#{xml_escape(item.severity)}\" message=\"#{xml_escape(item.message)}\" source=\"#{xml_escape(item.rule)}\"/>"
447
+ end
448
+ "<file name=\"#{xml_escape(display_path(file[:path]))}\">#{errors.join}</file>"
449
+ end
450
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?><checkstyle version=\"10.0\">#{entries.join}</checkstyle>"
451
+ end
452
+
453
+ def rdjson(files)
454
+ diagnostics = files.flat_map do |file|
455
+ file[:offenses].map do |item|
456
+ location = { path: display_path(item.location&.path || file[:path]) }
457
+ location[:range] = { start: { line: item.location.line } } if item.location&.line.to_i.positive?
458
+ { message: item.message, location: location, severity: item.severity.upcase,
459
+ code: { value: item.rule } }
460
+ end
461
+ end
462
+ JSON.pretty_generate(source: { name: "bklint", url: "https://github.com/breadkit/breadkit-lint" }, diagnostics: diagnostics)
463
+ end
464
+
465
+ def sarif(files, locale: "en")
316
466
  rules = (Registry.all.map do |rule|
317
467
  level = { "error" => "error", "warning" => "warning", "info" => "note" }.fetch(rule.severity, "error")
318
468
  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)
469
+ definition[:helpUri] = rule_help_url(rule.id)
321
470
  definition
322
471
  end + %w[Fatal/EvaluationError Fatal/RuleError Config/InvalidDisable].map do |id|
323
472
  { id: id, shortDescription: { text: id.split("/").last }, defaultConfiguration: { level: "error" } }
@@ -325,18 +474,21 @@ module Breadkit
325
474
  results = files.flat_map do |file|
326
475
  file[:offenses].map do |item|
327
476
  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 } }
477
+ message: { text: item.message }, properties: { targets: item.targets, state: item.state,
478
+ suggestion: item.suggestion || suggestion_for_rule(item.rule, locale) } }
329
479
  path = item.location&.path || file[:path]
330
- relative = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(Dir.pwd)).to_s.tr("\\", "/")
480
+ relative = display_path(path)
331
481
  uri = URI::DEFAULT_PARSER.escape(relative, /[^A-Za-z0-9\-._~\/]/)
332
482
  physical = { artifactLocation: { uri: uri, uriBaseId: "%SRCROOT%" } }
333
- physical[:region] = { startLine: item.location.line } if item.location&.line.to_i.positive?
483
+ if item.location&.line.to_i.positive?
484
+ physical[:region] = { startLine: item.location.line }
485
+ physical[:region][:startColumn] = item.column if item.column
486
+ end
334
487
  result[:locations] = [{ physicalLocation: physical }]
335
488
  result
336
489
  end
337
490
  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
491
+ root_uri = source_root_uri(Dir.pwd)
340
492
  JSON.pretty_generate(version: "2.1.0", "$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
341
493
  runs: [{ tool: { driver: { name: "bklint", version: VERSION, rules: rules } },
342
494
  originalUriBaseIds: { "%SRCROOT%" => { uri: root_uri } }, results: results }])
@@ -344,6 +496,49 @@ module Breadkit
344
496
 
345
497
  private
346
498
 
499
+ def rule_help_url(id)
500
+ path = File.expand_path("../../docs/rules/#{id}.md", __dir__)
501
+ "https://breadkit.github.io/breadkit-lint/rules/#{id}/" if File.file?(path)
502
+ end
503
+
504
+ def suggestion_for_rule(id, locale)
505
+ @guidance ||= {}
506
+ @guidance[locale] ||= begin
507
+ path = File.expand_path("../../locales/#{locale}.yml", __dir__)
508
+ YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false).fetch("guidance", {})
509
+ end
510
+ @guidance[locale][id]
511
+ end
512
+
513
+ def source_root_uri(root)
514
+ path = root.tr("\\", "/")
515
+ path = File.expand_path(path) unless path.match?(/\A[A-Za-z]:\//)
516
+ path = "/#{path}" if path.match?(/\A[A-Za-z]:\//)
517
+ escaped = URI::DEFAULT_PARSER.escape("#{path}/", /[^A-Za-z0-9\-._~\/:]/)
518
+ URI::File.build(path: escaped).to_s
519
+ end
520
+
521
+ def teach_guidance(rule_id, locale)
522
+ @teach_guidance ||= {}
523
+ @teach_guidance[[rule_id, locale]] ||= if locale != "en"
524
+ suggestion_for_rule(rule_id, locale)
525
+ else
526
+ path = File.expand_path("../../docs/rules/#{rule_id}.md", __dir__)
527
+ File.read(path, encoding: "UTF-8").split(/\n\s*\n/)[1]&.gsub(/\s+/, " ")&.strip if File.file?(path)
528
+ end
529
+ end
530
+
531
+ def display_path(path)
532
+ return path.tr("\\", "/").delete_prefix("./") unless Pathname.new(path).absolute?
533
+ absolute = File.expand_path(path)
534
+ Pathname.new(absolute).relative_path_from(Pathname.new(Dir.pwd)).to_s.tr("\\", "/")
535
+ end
536
+
537
+ def xml_escape(value)
538
+ value.to_s.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F]/, "\uFFFD")
539
+ .gsub(/[&<>"']/) { |char| { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&apos;" }.fetch(char) }
540
+ end
541
+
347
542
  def escape_data(value)
348
543
  value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
349
544
  end
@@ -355,51 +550,128 @@ module Breadkit
355
550
 
356
551
  class CLI
357
552
  def run(argv)
553
+ original_args = argv.dup
358
554
  options = { format: "text", fail_level: nil }
359
555
  parser = OptionParser.new do |opts|
360
556
  opts.banner = "Usage: bklint [options] [FILES...]"
361
- opts.on("-f", "--format FORMAT", %w[text json github sarif]) { |value| options[:format] = value }
557
+ opts.on("-f", "--format FORMAT", %w[text json github sarif markdown junit checkstyle rdjson]) { |value| options[:format] = value }
362
558
  opts.on("-o", "--out PATH") { |value| options[:out] = value }
363
559
  opts.on("-c", "--config PATH") { |value| options[:config] = value }
364
560
  opts.on("--fail-level LEVEL", %w[error warning info]) { |value| options[:fail_level] = value }
365
561
  opts.on("--only RULES") { |value| options[:only] = value.split(",") }
366
562
  opts.on("--except RULES") { |value| options[:except] = value.split(",") }
367
563
  opts.on("--switch-states MODE", %w[none single all]) { |value| options[:switch_states] = value }
564
+ opts.on("--state-budget COUNT", Integer) { |value| options[:state_budget] = value }
565
+ opts.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
566
+ opts.on("--stdin PATH") { |value| options[:stdin] = value }
567
+ opts.on("--baseline PATH") { |value| options[:baseline] = value }
568
+ opts.on("--generate-baseline PATH") { |value| options[:generate_baseline] = value }
569
+ opts.on("--diff REF") { |value| options[:diff] = value }
570
+ opts.on("--fix") { options[:fix] = true }
571
+ opts.on("--fix-check", "--fix-dry-run") { options[:fix_check] = true }
572
+ opts.on("--teach") { options[:teach] = true }
573
+ opts.on("--watch") { options[:watch] = true }
368
574
  opts.on("--list-rules") { options[:list_rules] = true }
369
575
  opts.on("--explain RULE") { |value| options[:explain] = value }
370
- opts.on("--locale LOCALE", %w[ja en]) { |value| options[:locale] = value }
576
+ opts.on("--locale LOCALE", %w[en ja zh ko]) { |value| options[:locale] = value }
371
577
  opts.on("-v", "--version") { puts "bklint #{VERSION}"; return 0 }
372
578
  opts.on("-h", "--help") { puts opts; return 0 }
373
579
  end
374
580
  parser.parse!(argv)
581
+ raise Error, "timeout must be positive" if options[:timeout] && !options[:timeout].positive?
582
+ raise Error, "state budget must be a positive integer" if options[:state_budget] && !options[:state_budget].positive?
583
+ raise Error, "--teach requires --format text" if options[:teach] && options[:format] != "text"
375
584
  locale = options[:locale] || locale_from_environment
376
585
  return list_rules(locale) if options[:list_rules]
377
586
  return explain(options[:explain], locale) if options[:explain]
378
- files = expand_inputs(argv)
587
+ raise Error, "--stdin accepts no additional file arguments" if options[:stdin] && !argv.empty?
588
+ if options[:stdin] && !options[:stdin].end_with?(".bk.rb", ".bk.yml", ".bk.yaml", ".bk.toml")
589
+ raise Error, "--stdin PATH requires a .bk.rb, .bk.yml, .bk.yaml, or .bk.toml path"
590
+ end
591
+ raise Error, "choose --baseline or --generate-baseline" if options[:baseline] && options[:generate_baseline]
592
+ raise Error, "--diff cannot be combined with a baseline" if options[:diff] && (options[:baseline] || options[:generate_baseline])
593
+ if options[:watch] && (options[:stdin] || options[:fix] || options[:fix_check] || options[:generate_baseline])
594
+ raise Error, "--watch cannot be combined with stdin, source fixes, or baseline generation"
595
+ end
596
+ if (options[:fix] || options[:fix_check]) && (options[:stdin] || options[:diff] || options[:baseline] || options[:generate_baseline] || options[:out])
597
+ raise Error, "source fixes cannot be combined with stdin, diff, baseline, or output options"
598
+ end
599
+ raise Error, "choose --fix or --fix-check" if options[:fix] && options[:fix_check]
600
+ files = options[:stdin] ? [options[:stdin]] : expand_inputs(argv)
601
+ if options[:watch]
602
+ original_args.delete_at(original_args.index("--watch"))
603
+ return watch(argv.empty? ? ["."] : argv, files, options, original_args)
604
+ end
605
+ source = $stdin.read if options[:stdin]
379
606
  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}?" : ""}"
607
+ inspect_files = lambda do
608
+ files.flat_map do |path|
609
+ config_path = options[:config] || nearest_config(path)
610
+ unless configs.key?(config_path)
611
+ configs[config_path] = Config.new(config_path)
612
+ configs[config_path].unknown_rules.each do |rule|
613
+ suggestion = DidYouMean::SpellChecker.new(dictionary: Registry.all.map(&:id)).correct(rule).first
614
+ warn "bklint: unknown rule #{rule.inspect}#{suggestion ? "; did you mean #{suggestion.inspect}?" : ""}"
615
+ end
387
616
  end
617
+ config = configs[config_path]
618
+ config.data["AllRules"] ||= {}
619
+ config.data["AllRules"]["SwitchStates"] = options[:switch_states] if options[:switch_states]
620
+ config.data["AllRules"]["StateBudget"] = options[:state_budget] if options[:state_budget]
621
+ Engine.new(config: config, locale: locale).run([path], only: options[:only], except: options[:except],
622
+ timeout: options[:timeout] || 10, source: source)
623
+ end
624
+ end
625
+ results = inspect_files.call
626
+ annotate = lambda do |items|
627
+ items.filter_map do |file|
628
+ path = file[:path]
629
+ next if file[:offenses].empty?
630
+ next unless path.end_with?(".bk.rb") && (source || File.file?(path))
631
+ editor = SourceEditor.new(path, source: source)
632
+ editor.annotate(file[:offenses])
633
+ [file, editor]
388
634
  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
635
  end
636
+ editors = annotate.call(results)
637
+ planned = if options[:fix] || options[:fix_check]
638
+ editors.filter_map do |file, editor|
639
+ next if File.symlink?(file[:path])
640
+ edits = editor.plan(file[:offenses])
641
+ [file, editor, edits] unless edits.empty?
642
+ end
643
+ else
644
+ []
645
+ end
646
+ planned.each do |file, _editor, edits|
647
+ edits.each { |edit| warn "bklint: #{options[:fix_check] ? 'would' : 'will'} #{edit.description} at #{file[:path]}:#{edit.line}" }
648
+ end
649
+ if options[:fix] && !planned.empty?
650
+ planned.each { |_file, editor, edits| editor.apply(edits) }
651
+ results = inspect_files.call
652
+ annotate.call(results)
653
+ end
654
+ if options[:generate_baseline]
655
+ count = Baseline.new(options[:generate_baseline]).write(results)
656
+ puts "Wrote #{count} baseline offenses to #{options[:generate_baseline]}"
657
+ return 0
658
+ end
659
+ results = Baseline.new(options[:baseline]).filter(results) if options[:baseline]
660
+ results = filter_diff(results, files, options, locale: locale) if options[:diff]
394
661
  formatter = Formatter.new
395
662
  output = case options[:format]
396
- when "json" then formatter.json(results)
663
+ when "json" then formatter.json(results, locale: locale)
397
664
  when "github" then formatter.github(results)
398
- when "sarif" then formatter.sarif(results)
399
- else formatter.text(results, locale: locale)
665
+ when "sarif" then formatter.sarif(results, locale: locale)
666
+ when "markdown" then formatter.markdown(results)
667
+ when "junit" then formatter.junit(results)
668
+ when "checkstyle" then formatter.checkstyle(results)
669
+ when "rdjson" then formatter.rdjson(results)
670
+ else formatter.text(results, locale: locale, teach: options[:teach])
400
671
  end
401
672
  options[:out] ? File.write(options[:out], output + "\n") : puts(output)
402
673
  return 2 if results.any? { |file| file[:offenses].any? { |item| item.rule.start_with?("Fatal/") } }
674
+ return 1 if options[:fix_check] && !planned.empty?
403
675
  results.any? do |file|
404
676
  config = configs[options[:config] || nearest_config(file[:path])]
405
677
  Engine.new(config: config).fail?([file], options[:fail_level] || config.fail_level)
@@ -411,9 +683,93 @@ module Breadkit
411
683
 
412
684
  private
413
685
 
686
+ def watch(roots, files, options, args)
687
+ watched = (files + [options[:config], options[:baseline]].compact + files.filter_map { |path| nearest_config(path) }).uniq
688
+ snapshot = watch_snapshot(roots, watched, options[:out])
689
+ warn "bklint: watching circuit and part files; press Ctrl-C to stop"
690
+ loop do
691
+ run(args.dup)
692
+ loop do
693
+ sleep 0.5
694
+ updated = watch_snapshot(roots, watched, options[:out])
695
+ next if updated == snapshot
696
+
697
+ snapshot = updated
698
+ break
699
+ end
700
+ end
701
+ rescue Interrupt
702
+ 0
703
+ end
704
+
705
+ def watch_snapshot(roots, watched, output)
706
+ # ponytail: poll the project tree; use file events if large projects make scans slow.
707
+ files = watched.dup
708
+ roots.each do |entry|
709
+ root = File.directory?(entry) ? entry : File.dirname(entry)
710
+ Find.find(root) do |path|
711
+ if File.directory?(path)
712
+ Find.prune if path != root && (File.basename(path).start_with?(".") || File.basename(path) == "node_modules")
713
+ elsif path.end_with?(".bk.rb", ".yml", ".yaml", ".toml", ".json")
714
+ files << path
715
+ end
716
+ end
717
+ end
718
+ files.map { |path| File.expand_path(path) }.uniq.sort.reject { |path| output && path == File.expand_path(output) }.to_h do |path|
719
+ stat = File.stat(path)
720
+ [path, [stat.mtime.to_r, stat.size]]
721
+ rescue Errno::ENOENT
722
+ [path, nil]
723
+ end
724
+ end
725
+
726
+ def filter_diff(results, files, options, locale:)
727
+ root, error, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
728
+ raise Error, "--diff requires a Git repository: #{error.strip}" unless status.success?
729
+
730
+ root = root.strip
731
+ Dir.mktmpdir("bklint-diff-") do |directory|
732
+ archive = File.join(directory, "base.tar")
733
+ base_root = File.join(directory, "base")
734
+ FileUtils.mkdir_p(base_root)
735
+ _out, error, status = Open3.capture3("git", "-C", root, "archive", "--format=tar", "-o", archive, options[:diff])
736
+ raise Error, "cannot read Git revision #{options[:diff]}: #{error.strip}" unless status.success?
737
+ _out, error, status = Open3.capture3("tar", "-xf", "base.tar", "-C", "base", chdir: directory)
738
+ raise Error, "cannot unpack Git revision #{options[:diff]}: #{error.strip}" unless status.success?
739
+
740
+ baseline_files = files.flat_map do |file|
741
+ relative = Pathname.new(File.expand_path(file)).relative_path_from(Pathname.new(root)).to_s
742
+ raise Error, "--diff input is outside the Git repository: #{file}" if relative == ".." || relative.start_with?("../")
743
+
744
+ base_path = File.join(base_root, relative)
745
+ next [] unless File.file?(base_path)
746
+
747
+ config = Config.new(options[:config] || nearest_config(base_path))
748
+ config.data["AllRules"] ||= {}
749
+ config.data["AllRules"]["SwitchStates"] = options[:switch_states] if options[:switch_states]
750
+ config.data["AllRules"]["StateBudget"] = options[:state_budget] if options[:state_budget]
751
+ Engine.new(config: config, locale: locale).run([base_path], only: options[:only], except: options[:except],
752
+ timeout: options[:timeout] || 10).map do |result|
753
+ result[:path] = File.expand_path(file)
754
+ result[:offenses].each do |item|
755
+ if item.location&.path&.start_with?("#{base_root}/")
756
+ item.location.path = File.join(root, item.location.path.delete_prefix("#{base_root}/"))
757
+ end
758
+ item.message = item.message.gsub(base_root, root)
759
+ end
760
+ result
761
+ end
762
+ end
763
+ baseline = Baseline.new(File.join(directory, "baseline.json"))
764
+ baseline.write(baseline_files)
765
+ baseline.filter(results)
766
+ end
767
+ end
768
+
414
769
  def locale_from_environment
415
770
  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"
771
+ language = value.to_s[0, 2].downcase
772
+ %w[ja zh ko].include?(language) ? language : "en"
417
773
  end
418
774
 
419
775
  def nearest_config(path)
@@ -435,7 +791,7 @@ module Breadkit
435
791
  Find.find(root) do |path|
436
792
  if File.directory?(path)
437
793
  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))
794
+ elsif path.end_with?(".bk.rb", ".bk.yml", ".bk.yaml", ".bk.toml") || (path.end_with?(".json") && ir_json?(path))
439
795
  found << path
440
796
  end
441
797
  end
@@ -446,7 +802,7 @@ module Breadkit
446
802
  def ir_json?(path)
447
803
  return true if path.end_with?(".bk.json", ".breadkit.json")
448
804
  data = JSON.parse(File.read(path, encoding: "UTF-8"))
449
- data.is_a?(Hash) && data["schema_version"] == 1
805
+ data.is_a?(Hash) && [1, 2].include?(data["schema_version"])
450
806
  rescue JSON::ParserError, Encoding::InvalidByteSequenceError
451
807
  false
452
808
  end
@@ -459,6 +815,13 @@ module Breadkit
459
815
  def explain(id, locale)
460
816
  rule = Registry.all.find { |item| item.id == id }
461
817
  raise Error, "unknown rule #{id}" unless rule
818
+ if locale != "en"
819
+ translations = YAML.safe_load(File.read(File.expand_path("../../locales/#{locale}.yml", __dir__), encoding: "UTF-8"), aliases: false) || {}
820
+ description = translations.dig("rules", rule.id) || rule.description
821
+ guidance = translations.dig("guidance", rule.id)
822
+ puts "# #{rule.id}\n\n#{description}#{guidance ? "\n\n#{guidance}" : ""}"
823
+ return 0
824
+ end
462
825
  path = File.expand_path("../../docs/rules/#{rule.id}.md", __dir__)
463
826
  puts File.file?(path) ? File.read(path, encoding: "UTF-8") : "#{rule.id} (#{rule.severity})\n#{localized_description(rule, locale)}"
464
827
  0