breadkit-lint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +54 -0
  5. data/SECURITY.md +5 -0
  6. data/config/default.yml +36 -0
  7. data/docs/rules/Electrical/DanglingWire.md +9 -0
  8. data/docs/rules/Electrical/FloatingPin.md +9 -0
  9. data/docs/rules/Electrical/MissingSeriesResistor.md +11 -0
  10. data/docs/rules/Electrical/NetLabelConflict.md +10 -0
  11. data/docs/rules/Electrical/NoCommonGround.md +10 -0
  12. data/docs/rules/Electrical/PowerPinUnconnected.md +9 -0
  13. data/docs/rules/Electrical/ReversePolarity.md +10 -0
  14. data/docs/rules/Electrical/ShortCircuit.md +9 -0
  15. data/docs/rules/Electrical/ShortedComponent.md +9 -0
  16. data/docs/rules/Electrical/SplitRail.md +10 -0
  17. data/docs/rules/Electrical/SupplyVoltageRange.md +10 -0
  18. data/docs/rules/Intent/ConnectionMismatch.md +9 -0
  19. data/docs/rules/Intent/UnknownNet.md +9 -0
  20. data/docs/rules/Layout/DuplicateRef.md +10 -0
  21. data/docs/rules/Layout/HoleConflict.md +10 -0
  22. data/docs/rules/Layout/InvalidHole.md +9 -0
  23. data/docs/rules/Layout/InvalidPlacement.md +9 -0
  24. data/docs/rules/Layout/NoFreeHole.md +7 -0
  25. data/docs/rules/Layout/PinsInSameStrip.md +9 -0
  26. data/docs/rules/Layout/SplitNetLabel.md +3 -0
  27. data/docs/rules/Layout/UnknownBoard.md +3 -0
  28. data/docs/rules/Layout/UnknownOption.md +5 -0
  29. data/docs/rules/Layout/UnknownPart.md +9 -0
  30. data/docs/rules/Layout/UnknownPin.md +9 -0
  31. data/docs/rules/Layout/UnknownTransistorModel.md +5 -0
  32. data/docs/rules/Layout/UnplacedPin.md +3 -0
  33. data/docs/rules/Style/WireColor.md +9 -0
  34. data/exe/bklint +5 -0
  35. data/lib/breadkit/lint/rules/electrical.rb +371 -0
  36. data/lib/breadkit/lint/rules/intent.rb +59 -0
  37. data/lib/breadkit/lint/rules/layout.rb +33 -0
  38. data/lib/breadkit/lint/rules/style.rb +43 -0
  39. data/lib/breadkit/lint/rules/support.rb +88 -0
  40. data/lib/breadkit/lint/rules.rb +205 -0
  41. data/lib/breadkit/lint/version.rb +7 -0
  42. data/lib/breadkit/lint.rb +476 -0
  43. data/locales/en.yml +28 -0
  44. data/locales/ja.yml +65 -0
  45. data/package-lock.json +1172 -0
  46. data/package.json +12 -0
  47. data/scripts/check_bad_examples.rb +37 -0
  48. data/sig/breadkit/lint.rbs +68 -0
  49. data/site/favicon.svg +7 -0
  50. data/site/index.html +95 -0
  51. data/site/styles.css +14 -0
  52. metadata +111 -0
@@ -0,0 +1,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