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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/SECURITY.md +5 -0
- data/config/default.yml +36 -0
- data/docs/rules/Electrical/DanglingWire.md +9 -0
- data/docs/rules/Electrical/FloatingPin.md +9 -0
- data/docs/rules/Electrical/MissingSeriesResistor.md +11 -0
- data/docs/rules/Electrical/NetLabelConflict.md +10 -0
- data/docs/rules/Electrical/NoCommonGround.md +10 -0
- data/docs/rules/Electrical/PowerPinUnconnected.md +9 -0
- data/docs/rules/Electrical/ReversePolarity.md +10 -0
- data/docs/rules/Electrical/ShortCircuit.md +9 -0
- data/docs/rules/Electrical/ShortedComponent.md +9 -0
- data/docs/rules/Electrical/SplitRail.md +10 -0
- data/docs/rules/Electrical/SupplyVoltageRange.md +10 -0
- data/docs/rules/Intent/ConnectionMismatch.md +9 -0
- data/docs/rules/Intent/UnknownNet.md +9 -0
- data/docs/rules/Layout/DuplicateRef.md +10 -0
- data/docs/rules/Layout/HoleConflict.md +10 -0
- data/docs/rules/Layout/InvalidHole.md +9 -0
- data/docs/rules/Layout/InvalidPlacement.md +9 -0
- data/docs/rules/Layout/NoFreeHole.md +7 -0
- data/docs/rules/Layout/PinsInSameStrip.md +9 -0
- data/docs/rules/Layout/SplitNetLabel.md +3 -0
- data/docs/rules/Layout/UnknownBoard.md +3 -0
- data/docs/rules/Layout/UnknownOption.md +5 -0
- data/docs/rules/Layout/UnknownPart.md +9 -0
- data/docs/rules/Layout/UnknownPin.md +9 -0
- data/docs/rules/Layout/UnknownTransistorModel.md +5 -0
- data/docs/rules/Layout/UnplacedPin.md +3 -0
- data/docs/rules/Style/WireColor.md +9 -0
- data/exe/bklint +5 -0
- data/lib/breadkit/lint/rules/electrical.rb +371 -0
- data/lib/breadkit/lint/rules/intent.rb +59 -0
- data/lib/breadkit/lint/rules/layout.rb +33 -0
- data/lib/breadkit/lint/rules/style.rb +43 -0
- data/lib/breadkit/lint/rules/support.rb +88 -0
- data/lib/breadkit/lint/rules.rb +205 -0
- data/lib/breadkit/lint/version.rb +7 -0
- data/lib/breadkit/lint.rb +476 -0
- data/locales/en.yml +28 -0
- data/locales/ja.yml +65 -0
- data/package-lock.json +1172 -0
- data/package.json +12 -0
- data/scripts/check_bad_examples.rb +37 -0
- data/sig/breadkit/lint.rbs +68 -0
- data/site/favicon.svg +7 -0
- data/site/index.html +95 -0
- data/site/styles.css +14 -0
- 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,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
|