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,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Lint
|
|
5
|
+
class Checks
|
|
6
|
+
def short_circuit(circuit, rule, state)
|
|
7
|
+
baseline = if state.closed_switches.empty?
|
|
8
|
+
[]
|
|
9
|
+
else
|
|
10
|
+
circuit.potentials(Breadkit::State.new(name: nil, closed_switches: [])).conflicts.map { |item| item[:supply].name }
|
|
11
|
+
end
|
|
12
|
+
circuit.potentials(state).conflicts.reject { |item| baseline.include?(item[:supply].name) }.map do |conflict|
|
|
13
|
+
first, second = conflict.values_at(:terminal_a, :terminal_b)
|
|
14
|
+
path = conflict[:path] || []
|
|
15
|
+
detail = "#{first} (#{conflict[:expected]} V) and #{second} (#{conflict[:actual]} V) conflict on #{conflict[:net]} (path: #{path.join(' → ')})"
|
|
16
|
+
message = translate("short_circuit", detail, first: first, first_voltage: conflict[:expected], second: second,
|
|
17
|
+
second_voltage: conflict[:actual], net: conflict[:net], path: path.join(" → "))
|
|
18
|
+
offense(rule.id, message, conflict[:location],
|
|
19
|
+
targets: { nets: [conflict[:net]], holes: path.select { |item| circuit.board.hole(item) },
|
|
20
|
+
wires: conflict[:wires] || [] }, state: state.name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def shorted_component(circuit, rule, state)
|
|
25
|
+
circuit.components.values.filter_map do |component|
|
|
26
|
+
pins = component.pins.values
|
|
27
|
+
next unless pins.length == 2 && pins.all?(&:hole_id)
|
|
28
|
+
next if circuit.board.hole(pins[0].hole_id).strip_id == circuit.board.hole(pins[1].hole_id).strip_id
|
|
29
|
+
next unless circuit.net_of("#{component.ref}.#{pins[0].name}", state) == circuit.net_of("#{component.ref}.#{pins[1].name}", state)
|
|
30
|
+
offense(rule.id, translate("shorted_component", "#{component.ref} pins are connected to the same net", ref: component.ref), component.location,
|
|
31
|
+
targets: { components: [component.ref], pins: pins.map { |pin| "#{component.ref}.#{pin.name}" } }, state: state.name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def floating_pins(circuit, rule, state)
|
|
36
|
+
circuit.components.values.flat_map do |component|
|
|
37
|
+
next [] if component.part.placement == "offboard"
|
|
38
|
+
shared = component.pins.values.group_by { |pin| pin.hole_id && circuit.board.hole(pin.hole_id)&.strip_id }
|
|
39
|
+
.reject { |strip, pins| !strip || pins.length < 2 }
|
|
40
|
+
.values.flatten.map(&:name)
|
|
41
|
+
component.pins.values.filter_map do |pin|
|
|
42
|
+
next if component.unused.include?(pin.number) || component.unused.include?(pin.name)
|
|
43
|
+
next if shared.include?(pin.name)
|
|
44
|
+
if pin.hole_id.nil?
|
|
45
|
+
next offense(rule.id, translate("unplaced_pin", "#{component.ref}.#{pin.name} has no board hole",
|
|
46
|
+
pin: "#{component.ref}.#{pin.name}"), component.location,
|
|
47
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{pin.name}"] }, state: state.name)
|
|
48
|
+
end
|
|
49
|
+
net = circuit.net_of("#{component.ref}.#{pin.name}", state)
|
|
50
|
+
next unless net
|
|
51
|
+
externally_connected = net.members.any? do |member|
|
|
52
|
+
member != "#{component.ref}.#{pin.name}" &&
|
|
53
|
+
(!member.include?(".") || member.split(".", 2).first != component.ref)
|
|
54
|
+
end
|
|
55
|
+
next if externally_connected
|
|
56
|
+
offense(rule.id, translate("floating_pin", "#{component.ref}.#{pin.name} has no external connection",
|
|
57
|
+
pin: "#{component.ref}.#{pin.name}"), component.location,
|
|
58
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{pin.name}"],
|
|
59
|
+
holes: [pin.hole_id].compact }, state: state.name)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dangling_wires(circuit, rule, state)
|
|
65
|
+
attachments = Hash.new { |hash, key| hash[key] = [] }
|
|
66
|
+
circuit.components.each_value do |component|
|
|
67
|
+
component.pins.each_value do |pin|
|
|
68
|
+
strip = pin.hole_id && circuit.board.hole(pin.hole_id)&.strip_id
|
|
69
|
+
attachments[strip] << component.ref if strip
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
circuit.supplies.each do |supply|
|
|
73
|
+
[supply.plus, supply.minus].each do |id|
|
|
74
|
+
strip = circuit.board.hole(id)&.strip_id
|
|
75
|
+
attachments[strip] << supply.name if strip
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
circuit.wires.each do |wire|
|
|
79
|
+
next if wire.electrical == false
|
|
80
|
+
[wire.from, wire.to].each do |id|
|
|
81
|
+
strip = circuit.board.hole(id)&.strip_id
|
|
82
|
+
attachments[strip] << wire.id if strip
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
circuit.wires.filter_map do |wire|
|
|
86
|
+
next if wire.electrical == false
|
|
87
|
+
|
|
88
|
+
attached = [wire.from, wire.to].map do |endpoint|
|
|
89
|
+
hole = circuit.board.hole(endpoint)
|
|
90
|
+
if hole.nil?
|
|
91
|
+
parsed = HoleId.parse(endpoint) rescue nil
|
|
92
|
+
component = parsed&.kind == :pin && circuit.components[parsed.ref]
|
|
93
|
+
next component&.part&.placement == "offboard"
|
|
94
|
+
end
|
|
95
|
+
hole && attachments[hole.strip_id].any? { |owner| owner != wire.id }
|
|
96
|
+
end
|
|
97
|
+
next if attached.all?
|
|
98
|
+
offense(rule.id, translate("dangling_wire", "#{wire.id} has an unconnected end", wire: wire.id),
|
|
99
|
+
wire.location, targets: { wires: [wire.id] }, state: state.name)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def split_rails(circuit, rule, _state)
|
|
104
|
+
circuit.board.holes.values.select(&:rail).group_by(&:rail).flat_map do |rail, holes|
|
|
105
|
+
segments = holes.group_by(&:strip_id)
|
|
106
|
+
next [] if segments.length < 2
|
|
107
|
+
entries = segments.map do |_strip, segment_holes|
|
|
108
|
+
ids = segment_holes.map(&:id)
|
|
109
|
+
net = circuit.nets.find { |item| !(item.holes & ids).empty? }
|
|
110
|
+
voltage = net && circuit.potentials.values[net.name]
|
|
111
|
+
[segment_holes, net, voltage]
|
|
112
|
+
end
|
|
113
|
+
next [] unless entries.any? { |_holes, _net, voltage| !voltage.nil? }
|
|
114
|
+
entries.filter_map do |segment_holes, net, voltage|
|
|
115
|
+
touched = net&.members&.any? do |member|
|
|
116
|
+
circuit.wires.any? { |wire| wire.id == member } || circuit.components.keys.any? { |ref| member.start_with?("#{ref}.") }
|
|
117
|
+
end
|
|
118
|
+
next unless net && voltage.nil? && touched
|
|
119
|
+
offense(rule.id, translate("split_rail", "used segment of #{rail} has no supply", rail: rail), nil,
|
|
120
|
+
targets: { holes: [segment_holes.first.id], nets: [net.name] })
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def missing_series_resistors(circuit, rule, state)
|
|
126
|
+
circuit.components.values.filter_map do |component|
|
|
127
|
+
next unless Array(component.part.data["flags"]).include?("needs_series_resistor")
|
|
128
|
+
polarity = component.part.data["polarity"] || {}
|
|
129
|
+
positive = component.pins[polarity["positive"]]
|
|
130
|
+
negative = component.pins[polarity["negative"]]
|
|
131
|
+
next unless positive && negative
|
|
132
|
+
high = circuit.net_of("#{component.ref}.#{positive.name}", state)
|
|
133
|
+
low = circuit.net_of("#{component.ref}.#{negative.name}", state)
|
|
134
|
+
next unless high && low
|
|
135
|
+
next unless unprotected_path?(circuit, high.name, low.name, component, state)
|
|
136
|
+
offense(rule.id, translate("missing_series_resistor", "#{component.ref} has no current-limiting resistor in series",
|
|
137
|
+
ref: component.ref), component.location,
|
|
138
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{positive.name}", "#{component.ref}.#{negative.name}"],
|
|
139
|
+
nets: [high.name, low.name] }, state: state.name)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unprotected_path?(circuit, start, finish, excluded_component, state)
|
|
144
|
+
adjacency = Hash.new { |hash, key| hash[key] = [] }
|
|
145
|
+
circuit.components.each_value do |component|
|
|
146
|
+
next if component == excluded_component || component.pins.length != 2
|
|
147
|
+
next unless component.part.data["category"] == "diode" || (component.part.id == "resistor" && !current_limiter?(component))
|
|
148
|
+
pins = component.pins.values
|
|
149
|
+
left, right = pins.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
|
|
150
|
+
next unless left && right && left != right
|
|
151
|
+
adjacency[left] << right
|
|
152
|
+
adjacency[right] << left
|
|
153
|
+
end
|
|
154
|
+
left_reachable = reachable_nets(adjacency, start)
|
|
155
|
+
right_reachable = reachable_nets(adjacency, finish)
|
|
156
|
+
source_pairs(circuit, state).any? do |high, low|
|
|
157
|
+
(left_reachable[high] && right_reachable[low]) ||
|
|
158
|
+
(left_reachable[low] && right_reachable[high])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def reachable_nets(adjacency, start)
|
|
163
|
+
seen, queue = { start => true }, [start]
|
|
164
|
+
until queue.empty?
|
|
165
|
+
current = queue.shift
|
|
166
|
+
adjacency[current].each do |target|
|
|
167
|
+
next if seen[target]
|
|
168
|
+
seen[target] = true
|
|
169
|
+
queue << target
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
seen
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def source_pairs(circuit, state)
|
|
176
|
+
pairs = circuit.supplies.filter_map do |supply|
|
|
177
|
+
high = circuit.net_of("#{supply.name}.+", state)&.name
|
|
178
|
+
low = circuit.net_of("#{supply.name}.-", state)&.name
|
|
179
|
+
[high, low] if high && low
|
|
180
|
+
end
|
|
181
|
+
circuit.components.each_value do |component|
|
|
182
|
+
next unless component.part.placement == "offboard" || component.part.data["category"] == "module"
|
|
183
|
+
pins = component.pins.values
|
|
184
|
+
grounds = pins.select { |pin| pin.role == "ground" }.filter_map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
|
|
185
|
+
outputs = pins.select { |pin| pin.role == "power" || pin.name.match?(/\A(?:GP|GPIO|D)\d+\z/i) }
|
|
186
|
+
outputs.each do |pin|
|
|
187
|
+
high = circuit.net_of("#{component.ref}.#{pin.name}", state)&.name
|
|
188
|
+
grounds.each { |low| pairs << [high, low] } if high
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
pairs
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def current_limiter?(component)
|
|
195
|
+
component.part.id == "resistor" && Breadkit::Value.parse(component.value).positive?
|
|
196
|
+
rescue ArgumentError
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def reverse_polarity(circuit, rule, state)
|
|
201
|
+
ranges, domains = potential_ranges(circuit, state)
|
|
202
|
+
circuit.components.values.filter_map do |component|
|
|
203
|
+
polarity = component.part.data["polarity"]
|
|
204
|
+
next unless polarity
|
|
205
|
+
positive = component.pins[polarity["positive"]]
|
|
206
|
+
negative = component.pins[polarity["negative"]]
|
|
207
|
+
next unless positive && negative
|
|
208
|
+
high = circuit.net_of("#{component.ref}.#{positive.name}", state)
|
|
209
|
+
low = circuit.net_of("#{component.ref}.#{negative.name}", state)
|
|
210
|
+
next unless high && low && ranges[high.name] && ranges[low.name]
|
|
211
|
+
next unless domains[high.name] && domains[high.name] == domains[low.name]
|
|
212
|
+
next unless ranges[high.name][1] < ranges[low.name][0]
|
|
213
|
+
offense(rule.id, translate("reverse_polarity", "#{component.ref} positive pin is below its negative pin",
|
|
214
|
+
ref: component.ref), component.location,
|
|
215
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{positive.name}", "#{component.ref}.#{negative.name}"],
|
|
216
|
+
nets: [high.name, low.name] }, state: state.name)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def potential_ranges(circuit, state)
|
|
221
|
+
# ponytail: resistor-only DC leaves active-device voltages unknown; add device models when those cases matter.
|
|
222
|
+
known = circuit.potentials(state).values
|
|
223
|
+
ranges = known.transform_values { |value| [value, value] }
|
|
224
|
+
domains = supply_domains(circuit, state)
|
|
225
|
+
adjacency = Hash.new { |hash, key| hash[key] = [] }
|
|
226
|
+
circuit.components.each_value do |component|
|
|
227
|
+
next unless current_limiter?(component) && component.pins.length == 2
|
|
228
|
+
left, right = component.pins.values.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
|
|
229
|
+
next unless left && right
|
|
230
|
+
conductance = 1.0 / Breadkit::Value.parse(component.value)
|
|
231
|
+
adjacency[left] << [right, conductance]
|
|
232
|
+
adjacency[right] << [left, conductance]
|
|
233
|
+
end
|
|
234
|
+
visited = {}
|
|
235
|
+
adjacency.each_key do |start|
|
|
236
|
+
next if visited[start]
|
|
237
|
+
queue = [start]
|
|
238
|
+
visited[start] = true
|
|
239
|
+
nodes = []
|
|
240
|
+
until queue.empty?
|
|
241
|
+
node = queue.shift
|
|
242
|
+
nodes << node
|
|
243
|
+
adjacency[node].each do |neighbor, _conductance|
|
|
244
|
+
next if visited[neighbor]
|
|
245
|
+
visited[neighbor] = true
|
|
246
|
+
queue << neighbor
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
anchors = nodes.filter_map { |name| domains[name] }.uniq
|
|
250
|
+
next unless anchors.length == 1
|
|
251
|
+
fixed = known.select { |name, _value| domains[name] == anchors.first }
|
|
252
|
+
unknown = nodes.reject { |name| fixed.key?(name) }
|
|
253
|
+
index = unknown.each_with_index.to_h
|
|
254
|
+
matrix = Array.new(unknown.length) { Array.new(unknown.length + 1, 0.0) }
|
|
255
|
+
unknown.each_with_index do |node, row|
|
|
256
|
+
adjacency[node].each do |neighbor, conductance|
|
|
257
|
+
matrix[row][row] += conductance
|
|
258
|
+
fixed.key?(neighbor) ? matrix[row][-1] += conductance * fixed[neighbor] : matrix[row][index[neighbor]] -= conductance
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
solution = solve_linear(matrix)
|
|
262
|
+
if solution
|
|
263
|
+
unknown.each_with_index do |name, index|
|
|
264
|
+
ranges[name] = [solution[index], solution[index]]
|
|
265
|
+
domains[name] = anchors.first
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
[ranges, domains]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def supply_domains(circuit, state)
|
|
273
|
+
adjacency = Hash.new { |hash, key| hash[key] = [] }
|
|
274
|
+
circuit.supplies.each do |supply|
|
|
275
|
+
high = circuit.net_of("#{supply.name}.+", state)&.name
|
|
276
|
+
low = circuit.net_of("#{supply.name}.-", state)&.name
|
|
277
|
+
next unless high && low
|
|
278
|
+
adjacency[high] << low
|
|
279
|
+
adjacency[low] << high
|
|
280
|
+
end
|
|
281
|
+
domains = {}
|
|
282
|
+
adjacency.each_key do |start|
|
|
283
|
+
next if domains.key?(start)
|
|
284
|
+
domains[start] = start
|
|
285
|
+
queue = [start]
|
|
286
|
+
until queue.empty?
|
|
287
|
+
adjacency[queue.shift].each do |neighbor|
|
|
288
|
+
next if domains.key?(neighbor)
|
|
289
|
+
domains[neighbor] = start
|
|
290
|
+
queue << neighbor
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
domains
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def solve_linear(matrix)
|
|
298
|
+
size = matrix.length
|
|
299
|
+
size.times do |column|
|
|
300
|
+
pivot = (column...size).max_by { |row| matrix[row][column].abs }
|
|
301
|
+
return nil if matrix[pivot][column].abs < 1e-12
|
|
302
|
+
matrix[column], matrix[pivot] = matrix[pivot], matrix[column]
|
|
303
|
+
divisor = matrix[column][column]
|
|
304
|
+
(column..size).each { |offset| matrix[column][offset] /= divisor }
|
|
305
|
+
size.times do |row|
|
|
306
|
+
next if row == column
|
|
307
|
+
factor = matrix[row][column]
|
|
308
|
+
(column..size).each { |offset| matrix[row][offset] -= factor * matrix[column][offset] }
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
matrix.map(&:last)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def power_pins(circuit, rule, state)
|
|
315
|
+
grounds = circuit.supplies.filter_map { |supply| circuit.net_of("#{supply.name}.-", state)&.name }
|
|
316
|
+
values = circuit.potentials(state).values
|
|
317
|
+
circuit.components.values.reject { |component| component.part.placement == "offboard" }.flat_map do |component|
|
|
318
|
+
component.pins.values.filter_map do |pin|
|
|
319
|
+
next unless %w[power ground].include?(pin.role)
|
|
320
|
+
next if component.unused.include?(pin.number) || component.unused.include?(pin.name)
|
|
321
|
+
net = circuit.net_of("#{component.ref}.#{pin.name}", state)
|
|
322
|
+
voltage = net && values[net.name]
|
|
323
|
+
connected = if pin.role == "ground"
|
|
324
|
+
grounds.include?(net&.name)
|
|
325
|
+
else
|
|
326
|
+
!voltage.nil? && grounds.any? { |name| values.key?(name) && voltage > values[name] }
|
|
327
|
+
end
|
|
328
|
+
next if connected
|
|
329
|
+
message = translate("power_pin", "#{component.ref}.#{pin.name} is not connected to a valid #{pin.role} supply",
|
|
330
|
+
pin: "#{component.ref}.#{pin.name}", role: pin.role)
|
|
331
|
+
offense(rule.id, message, component.location,
|
|
332
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{pin.name}"],
|
|
333
|
+
holes: [pin.hole_id].compact }, state: state.name)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def supply_ranges(circuit, rule, state)
|
|
339
|
+
values = circuit.potentials(state).values
|
|
340
|
+
domains = supply_domains(circuit, state)
|
|
341
|
+
circuit.components.values.flat_map do |component|
|
|
342
|
+
range = component.part.data["supply_range"]
|
|
343
|
+
next [] unless range
|
|
344
|
+
powers = component.pins.values.select { |pin| pin.role == "power" }
|
|
345
|
+
grounds = component.pins.values.select { |pin| pin.role == "ground" }
|
|
346
|
+
powers.product(grounds).filter_map do |power, ground|
|
|
347
|
+
high = circuit.net_of("#{component.ref}.#{power.name}", state)
|
|
348
|
+
low = circuit.net_of("#{component.ref}.#{ground.name}", state)
|
|
349
|
+
next unless high && low && values.key?(high.name) && values.key?(low.name)
|
|
350
|
+
next unless domains[high.name] && domains[high.name] == domains[low.name]
|
|
351
|
+
voltage = values[high.name] - values[low.name]
|
|
352
|
+
next if voltage >= range[0].to_f && voltage <= range[1].to_f
|
|
353
|
+
message = translate("supply_range", "#{component.ref} #{power.name}/#{ground.name} supply is #{voltage.round(3)} V; expected #{range[0]}..#{range[1]} V",
|
|
354
|
+
ref: component.ref, power: power.name, ground: ground.name, voltage: voltage.round(3), minimum: range[0], maximum: range[1])
|
|
355
|
+
offense(rule.id, message, component.location,
|
|
356
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{power.name}", "#{component.ref}.#{ground.name}"],
|
|
357
|
+
nets: [high.name, low.name] }, state: state.name)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def common_ground(circuit, rule, state)
|
|
363
|
+
return [] if circuit.supplies.length < 2
|
|
364
|
+
grounds = circuit.supplies.filter_map { |supply| circuit.net_of("#{supply.name}.-", state)&.name }.uniq
|
|
365
|
+
return [] if grounds.length <= 1
|
|
366
|
+
[offense(rule.id, translate("common_ground", "power supplies do not share a ground"), nil, targets: { nets: grounds })]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Lint
|
|
5
|
+
class Checks
|
|
6
|
+
def label_conflicts(circuit, rule, state)
|
|
7
|
+
circuit.nets(state).filter_map do |net|
|
|
8
|
+
next unless net.labels.length > 1
|
|
9
|
+
message = translate("label_conflict", "net has conflicting labels: #{net.labels.join(', ')}", labels: net.labels.join(", "))
|
|
10
|
+
offense(rule.id, message, nil, targets: { nets: [net.name] }, state: state.name)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def expectations(circuit, rule, state)
|
|
15
|
+
return [] if rule.id == "Intent/UnknownNet" && circuit.diagnostics.any? { |item| item.code == "unknown_net" }
|
|
16
|
+
circuit.expectations.flat_map do |expectation|
|
|
17
|
+
strict = expectation["strict"] || expectation[:strict]
|
|
18
|
+
Array(expectation["entries"] || expectation[:entries]).filter_map do |item|
|
|
19
|
+
kind = item["kind"] || item[:kind]
|
|
20
|
+
refs = item["refs"] || item[:refs] || []
|
|
21
|
+
expected_name = item["name"] || item[:name]
|
|
22
|
+
loc = item["location"] || item[:location]
|
|
23
|
+
names = refs.map(&:to_s)
|
|
24
|
+
nets = names.empty? && kind == "net" ? [circuit.net_of(expected_name, state)] : names.map { |reference| circuit.net_of(reference, state) }
|
|
25
|
+
if nets.any?(&:nil?)
|
|
26
|
+
next unless rule.id == "Intent/UnknownNet"
|
|
27
|
+
references = (names.empty? ? [expected_name] : names).join(", ")
|
|
28
|
+
offense(rule.id, translate("unknown_expectation", "unknown reference in expectation: #{references}", refs: references),
|
|
29
|
+
location_from(loc), targets: {})
|
|
30
|
+
elsif rule.id == "Intent/ConnectionMismatch" && kind == "connected" && nets.map(&:name).uniq.length > 1
|
|
31
|
+
message = translate("expected_connected", "expected #{names.join(', ')} to be connected", refs: names.join(", "))
|
|
32
|
+
offense(rule.id, message, location_from(loc), targets: { nets: nets.map(&:name) }, state: state.name)
|
|
33
|
+
elsif rule.id == "Intent/ConnectionMismatch" && kind == "isolated" && nets.map(&:name).uniq.length != names.length
|
|
34
|
+
message = translate("expected_isolated", "expected #{names.join(', ')} to be isolated", refs: names.join(", "))
|
|
35
|
+
offense(rule.id, message, location_from(loc), targets: { nets: nets.map(&:name) }, state: state.name)
|
|
36
|
+
elsif rule.id == "Intent/ConnectionMismatch" && kind == "net"
|
|
37
|
+
actual_pins = nets.compact.flat_map(&:members).select do |member|
|
|
38
|
+
circuit.components.keys.any? { |ref| member.start_with?("#{ref}.") }
|
|
39
|
+
end.uniq
|
|
40
|
+
declared_pins = names.map { |name| canonical_pin(circuit, name) }
|
|
41
|
+
extra_pins = strict ? actual_pins - declared_pins : []
|
|
42
|
+
name_matches = nets.first.name == expected_name.to_s
|
|
43
|
+
next if nets.map(&:name).uniq.length == 1 && extra_pins.empty? && name_matches
|
|
44
|
+
detail = if !extra_pins.empty?
|
|
45
|
+
translate("undeclared_pins", "undeclared pins on expected net: #{extra_pins.join(', ')}", pins: extra_pins.join(", "))
|
|
46
|
+
elsif !name_matches
|
|
47
|
+
translate("expected_net_name", "expected net #{expected_name}, found #{nets.first.name}", expected: expected_name, actual: nets.first.name)
|
|
48
|
+
else
|
|
49
|
+
translate("expected_one_net", "expected #{names.join(', ')} on one net", refs: names.join(", "))
|
|
50
|
+
end
|
|
51
|
+
offense(rule.id, detail, location_from(loc), targets: { nets: nets.compact.map(&:name), components: extra_pins.map { |pin| pin.split('.', 2).first } }, state: state.name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Lint
|
|
5
|
+
class Checks
|
|
6
|
+
def same_strip(circuit, rule, _state)
|
|
7
|
+
circuit.components.values.flat_map do |component|
|
|
8
|
+
next [] if component.part.placement == "offboard"
|
|
9
|
+
pins = component.pins.values.select(&:hole_id)
|
|
10
|
+
groups = pins.group_by { |pin| circuit.board.hole(pin.hole_id)&.strip_id }
|
|
11
|
+
groups.flat_map do |strip, members|
|
|
12
|
+
next [] unless strip && members.length > 1
|
|
13
|
+
members.combination(2).filter_map do |left, right|
|
|
14
|
+
allowed = Array(component.part.data["same_strip_ok"]).any? do |pair|
|
|
15
|
+
pair = pair.map(&:to_s)
|
|
16
|
+
left_keys = [left.name, left.number]
|
|
17
|
+
right_keys = [right.name, right.number]
|
|
18
|
+
(left_keys & pair).any? && (right_keys & pair).any?
|
|
19
|
+
end
|
|
20
|
+
next if allowed
|
|
21
|
+
message = translate("same_strip", "#{component.ref} pins #{left.name}, #{right.name} share strip #{strip}",
|
|
22
|
+
ref: component.ref, left: left.name, right: right.name, strip: strip)
|
|
23
|
+
offense(rule.id, message, component.location,
|
|
24
|
+
targets: { components: [component.ref], pins: ["#{component.ref}.#{left.name}", "#{component.ref}.#{right.name}"],
|
|
25
|
+
holes: [left.hole_id, right.hole_id] })
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Lint
|
|
5
|
+
class Checks
|
|
6
|
+
def wire_colors(circuit, rule, _state)
|
|
7
|
+
positive = Array(@config.data.dig(rule.id, "PositiveColors") || %w[red orange]).map { |color| color.downcase }
|
|
8
|
+
ground = Array(@config.data.dig(rule.id, "GroundColors") || %w[black blue gray]).map { |color| color.downcase }
|
|
9
|
+
circuit.wires.filter_map do |wire|
|
|
10
|
+
net = circuit.nets.find { |item| item.members.include?(wire.id) }
|
|
11
|
+
voltage = net && circuit.potentials.values[net.name]
|
|
12
|
+
expected = voltage&.positive? ? positive : (voltage == 0.0 ? ground : nil)
|
|
13
|
+
next unless expected && wire.color && !expected.include?(color_family(wire.color))
|
|
14
|
+
message = translate("wire_color", "#{wire.id} uses #{wire.color}; expected #{expected.join(' or ')}",
|
|
15
|
+
wire: wire.id, color: wire.color, expected: expected.join(" / "))
|
|
16
|
+
offense(rule.id, message, wire.location,
|
|
17
|
+
targets: { wires: [wire.id], nets: [net.name] })
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def color_family(color)
|
|
22
|
+
value = color.to_s.downcase
|
|
23
|
+
return value unless value.match?(/\A#(?:[0-9a-f]{3}|[0-9a-f]{6})\z/)
|
|
24
|
+
digits = value.delete_prefix("#")
|
|
25
|
+
digits = digits.chars.map { |digit| digit * 2 }.join if digits.length == 3
|
|
26
|
+
red, green, blue = digits.scan(/../).map { |pair| pair.to_i(16) }
|
|
27
|
+
max, min = [red, green, blue].max, [red, green, blue].min
|
|
28
|
+
return "black" if max <= 76
|
|
29
|
+
return "gray" if max == min || (max - min).fdiv(max) < 0.25
|
|
30
|
+
hue = case max
|
|
31
|
+
when red then 60.0 * (green - blue) / (max - min)
|
|
32
|
+
when green then 120.0 + 60.0 * (blue - red) / (max - min)
|
|
33
|
+
else 240.0 + 60.0 * (red - green) / (max - min)
|
|
34
|
+
end % 360
|
|
35
|
+
return "red" if hue < 15 || hue >= 345
|
|
36
|
+
return "orange" if hue < 50
|
|
37
|
+
return "blue" if hue.between?(195, 255)
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Lint
|
|
5
|
+
class Checks
|
|
6
|
+
def initialize(config, messages)
|
|
7
|
+
@config, @messages = config, messages
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def diagnostics(circuit, rule, _state)
|
|
11
|
+
circuit.diagnostics.filter_map do |diagnostic|
|
|
12
|
+
next unless diagnostic_rule(diagnostic.code) == rule.id
|
|
13
|
+
offense(rule.id, diagnostic_message(diagnostic), diagnostic.location,
|
|
14
|
+
targets: diagnostic_targets(circuit, diagnostic.targets), state: nil)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def diagnostic_rule(code)
|
|
19
|
+
return "Intent/UnknownNet" if code == "unknown_net"
|
|
20
|
+
Registry.all.find { |rule| rule < DiagnosticRule && rule.id.split("/").last.gsub(/([a-z])([A-Z])/, '\\1_\\2').downcase == code }&.id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def diagnostic_targets(circuit, values)
|
|
24
|
+
ids = { components: circuit.components.keys, wires: circuit.wires.map(&:id),
|
|
25
|
+
holes: circuit.board.holes.keys, nets: circuit.nets.map(&:name) }
|
|
26
|
+
Array(values).each_with_object({}) do |value, targets|
|
|
27
|
+
kind = ids.find { |_type, known| known.include?(value) }&.first
|
|
28
|
+
(targets[kind] ||= []) << value if kind
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def diagnostic_message(diagnostic)
|
|
33
|
+
target = Array(diagnostic.targets).join(", ")
|
|
34
|
+
target = diagnostic.message[/unknown pin ([^;]+)/, 1] || target if diagnostic.code == "unknown_pin"
|
|
35
|
+
option = diagnostic.message[/unknown component option ([^ ]+)/, 1] if diagnostic.code == "unknown_option"
|
|
36
|
+
translate("diagnostic_#{diagnostic.code}", diagnostic.message, target: target, option: option)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def translate(key, english, **values)
|
|
40
|
+
template = @messages[key]
|
|
41
|
+
template ? format(template, **values) : english
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def offense(rule_id, message, location, targets: {}, state: nil)
|
|
45
|
+
rule = Registry.all.find { |item| item.id == rule_id }
|
|
46
|
+
Offense.new(rule: rule_id, severity: rule ? @config.severity(rule) : "error", message: message,
|
|
47
|
+
location: location, targets: targets, state: state)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def suppress(offenses, disables, circuit)
|
|
51
|
+
invalid_disables = []
|
|
52
|
+
invalid = disables.filter_map do |disable|
|
|
53
|
+
rule_id = disable[:rule] || disable["rule"]
|
|
54
|
+
reason = disable[:reason] || disable["reason"]
|
|
55
|
+
message = if Registry.all.none? { |rule| rule.id == rule_id }
|
|
56
|
+
translate("unknown_disable", "unknown rule in lint_disable: #{rule_id}", rule: rule_id)
|
|
57
|
+
elsif @config.data.dig("AllRules", "RequireDisableReason") && reason.to_s.strip.empty?
|
|
58
|
+
translate("disable_reason", "lint_disable for #{rule_id} requires a reason", rule: rule_id)
|
|
59
|
+
end
|
|
60
|
+
next unless message
|
|
61
|
+
invalid_disables << disable
|
|
62
|
+
offense("Config/InvalidDisable", message, location_from(disable[:location] || disable["location"]))
|
|
63
|
+
end
|
|
64
|
+
valid = disables - invalid_disables
|
|
65
|
+
kept = offenses.reject do |item|
|
|
66
|
+
valid.any? do |disable|
|
|
67
|
+
rule = disable[:rule] || disable["rule"]
|
|
68
|
+
target = disable[:on] || disable["on"]
|
|
69
|
+
rule == item.rule && (!target || item.targets.values.flatten.include?(canonical_pin(circuit, target)))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
kept + invalid
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def canonical_pin(circuit, reference)
|
|
76
|
+
ref, pin = reference.to_s.split(".", 2)
|
|
77
|
+
resolved = pin && circuit.components[ref]&.pin(pin)
|
|
78
|
+
resolved ? "#{ref}.#{resolved.name}" : reference.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def location_from(value)
|
|
82
|
+
return value if value.is_a?(SourceLocation)
|
|
83
|
+
return nil unless value
|
|
84
|
+
SourceLocation.new(path: value["path"] || value[:path], line: value["line"] || value[:line])
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|