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.
- checksums.yaml +4 -4
- data/.yamllint +16 -0
- data/CHANGELOG.md +30 -2
- data/README.md +55 -40
- data/action.yml +42 -0
- data/config/default.yml +33 -0
- data/docs/REFERENCE.md +129 -0
- data/docs/rules/Electrical/CapacitorVoltageRating.md +5 -0
- data/docs/rules/Electrical/FloatingInput.md +11 -0
- data/docs/rules/Electrical/GpioOvercurrent.md +18 -0
- data/docs/rules/Electrical/I2CAddressConflict.md +5 -0
- data/docs/rules/Electrical/I2CPullupMissing.md +5 -0
- data/docs/rules/Electrical/LedOvercurrent.md +5 -0
- data/docs/rules/Electrical/MinimumResistance.md +14 -0
- data/docs/rules/Electrical/MissingBaseResistor.md +5 -0
- data/docs/rules/Electrical/MissingDecouplingCapacitor.md +5 -0
- data/docs/rules/Electrical/MissingFlybackDiode.md +18 -0
- data/docs/rules/Electrical/MissingPullResistor.md +17 -0
- data/docs/rules/Electrical/MissingSeriesResistor.md +3 -1
- data/docs/rules/Electrical/NoCommonGround.md +1 -0
- data/docs/rules/Electrical/RailPolarityMismatch.md +10 -0
- data/docs/rules/Electrical/ResistorPowerRating.md +5 -0
- data/docs/rules/Electrical/ReversePolarity.md +1 -1
- data/docs/rules/Electrical/SupplyOverload.md +11 -0
- data/docs/rules/Electrical/SupplyVoltageRange.md +1 -0
- data/docs/rules/Electrical/VoltageDomainMismatch.md +6 -0
- data/docs/rules/Intent/ConnectionMismatch.md +3 -1
- data/docs/rules/Intent/MeasurementUnavailable.md +10 -0
- data/docs/rules/Layout/AmbiguousSupplySource.md +5 -0
- data/docs/rules/Layout/BodyOverlap.md +11 -0
- data/docs/rules/Layout/HoleCovered.md +11 -0
- data/docs/rules/Layout/InvalidColor.md +3 -0
- data/docs/rules/Layout/InvalidOption.md +3 -0
- data/docs/rules/Layout/InvalidRoute.md +3 -0
- data/docs/rules/Layout/InvalidValue.md +3 -0
- data/docs/rules/Layout/InvalidWireId.md +3 -0
- data/docs/rules/Layout/LeadSpan.md +21 -0
- data/docs/rules/Layout/PartOverride.md +3 -0
- data/docs/rules/Layout/UnknownSupplySource.md +5 -0
- data/docs/rules/Layout/UnmatchedParts.md +3 -0
- data/docs/rules/Layout/WireOverIC.md +11 -0
- data/docs/rules/Lint/RedundantDisable.md +3 -0
- data/docs/rules/Lint/UnknownRuleInDisable.md +5 -0
- data/lib/breadkit/lint/baseline.rb +38 -0
- data/lib/breadkit/lint/rake_task.rb +22 -0
- data/lib/breadkit/lint/rules/electrical.rb +580 -30
- data/lib/breadkit/lint/rules/intent.rb +69 -4
- data/lib/breadkit/lint/rules/layout.rb +148 -0
- data/lib/breadkit/lint/rules/style.rb +36 -7
- data/lib/breadkit/lint/rules/support.rb +45 -11
- data/lib/breadkit/lint/rules.rb +205 -2
- data/lib/breadkit/lint/source_editor.rb +138 -0
- data/lib/breadkit/lint/version.rb +1 -1
- data/lib/breadkit/lint.rb +422 -59
- data/lib/guard/breadkit/templates/Guardfile +7 -0
- data/lib/guard/breadkit.rb +26 -0
- data/locales/en.yml +82 -0
- data/locales/ja.yml +130 -4
- data/locales/ko.yml +191 -0
- data/locales/zh.yml +191 -0
- data/package-lock.json +103 -0
- data/package.json +2 -1
- data/scripts/action-run.sh +51 -0
- data/scripts/build-rule-pages.mjs +87 -0
- data/scripts/check-rule-pages.mjs +18 -0
- data/scripts/pre-commit +24 -0
- data/sig/breadkit/lint.rbs +12 -1
- data/site/index.html +9 -6
- data/site/schemas/lint-v1.json +71 -0
- data/site/styles.css +13 -0
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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:
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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].
|
|
345
|
+
entries = file[:offenses].flat_map do |item|
|
|
265
346
|
level = { "error" => "E", "warning" => "W", "info" => "I" }.fetch(item.severity, "E")
|
|
266
|
-
state =
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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:
|
|
292
|
-
{ rule: item.rule, severity: item.severity, message: item.message,
|
|
293
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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| { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.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
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
configs
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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.
|
|
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"]
|
|
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
|