breadkit-lint 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.yamllint +16 -0
  3. data/CHANGELOG.md +30 -2
  4. data/README.md +55 -40
  5. data/action.yml +42 -0
  6. data/config/default.yml +33 -0
  7. data/docs/REFERENCE.md +129 -0
  8. data/docs/rules/Electrical/CapacitorVoltageRating.md +5 -0
  9. data/docs/rules/Electrical/FloatingInput.md +11 -0
  10. data/docs/rules/Electrical/GpioOvercurrent.md +18 -0
  11. data/docs/rules/Electrical/I2CAddressConflict.md +5 -0
  12. data/docs/rules/Electrical/I2CPullupMissing.md +5 -0
  13. data/docs/rules/Electrical/LedOvercurrent.md +5 -0
  14. data/docs/rules/Electrical/MinimumResistance.md +14 -0
  15. data/docs/rules/Electrical/MissingBaseResistor.md +5 -0
  16. data/docs/rules/Electrical/MissingDecouplingCapacitor.md +5 -0
  17. data/docs/rules/Electrical/MissingFlybackDiode.md +18 -0
  18. data/docs/rules/Electrical/MissingPullResistor.md +17 -0
  19. data/docs/rules/Electrical/MissingSeriesResistor.md +3 -1
  20. data/docs/rules/Electrical/NoCommonGround.md +1 -0
  21. data/docs/rules/Electrical/RailPolarityMismatch.md +10 -0
  22. data/docs/rules/Electrical/ResistorPowerRating.md +5 -0
  23. data/docs/rules/Electrical/ReversePolarity.md +1 -1
  24. data/docs/rules/Electrical/SupplyOverload.md +11 -0
  25. data/docs/rules/Electrical/SupplyVoltageRange.md +1 -0
  26. data/docs/rules/Electrical/VoltageDomainMismatch.md +6 -0
  27. data/docs/rules/Intent/ConnectionMismatch.md +3 -1
  28. data/docs/rules/Intent/MeasurementUnavailable.md +10 -0
  29. data/docs/rules/Layout/AmbiguousSupplySource.md +5 -0
  30. data/docs/rules/Layout/BodyOverlap.md +11 -0
  31. data/docs/rules/Layout/HoleCovered.md +11 -0
  32. data/docs/rules/Layout/InvalidColor.md +3 -0
  33. data/docs/rules/Layout/InvalidOption.md +3 -0
  34. data/docs/rules/Layout/InvalidRoute.md +3 -0
  35. data/docs/rules/Layout/InvalidValue.md +3 -0
  36. data/docs/rules/Layout/InvalidWireId.md +3 -0
  37. data/docs/rules/Layout/LeadSpan.md +21 -0
  38. data/docs/rules/Layout/PartOverride.md +3 -0
  39. data/docs/rules/Layout/UnknownSupplySource.md +5 -0
  40. data/docs/rules/Layout/UnmatchedParts.md +3 -0
  41. data/docs/rules/Layout/WireOverIC.md +11 -0
  42. data/docs/rules/Lint/RedundantDisable.md +3 -0
  43. data/docs/rules/Lint/UnknownRuleInDisable.md +5 -0
  44. data/lib/breadkit/lint/baseline.rb +38 -0
  45. data/lib/breadkit/lint/rake_task.rb +22 -0
  46. data/lib/breadkit/lint/rules/electrical.rb +580 -30
  47. data/lib/breadkit/lint/rules/intent.rb +69 -4
  48. data/lib/breadkit/lint/rules/layout.rb +148 -0
  49. data/lib/breadkit/lint/rules/style.rb +36 -7
  50. data/lib/breadkit/lint/rules/support.rb +45 -11
  51. data/lib/breadkit/lint/rules.rb +205 -2
  52. data/lib/breadkit/lint/source_editor.rb +138 -0
  53. data/lib/breadkit/lint/version.rb +1 -1
  54. data/lib/breadkit/lint.rb +422 -59
  55. data/lib/guard/breadkit/templates/Guardfile +7 -0
  56. data/lib/guard/breadkit.rb +26 -0
  57. data/locales/en.yml +82 -0
  58. data/locales/ja.yml +130 -4
  59. data/locales/ko.yml +191 -0
  60. data/locales/zh.yml +191 -0
  61. data/package-lock.json +103 -0
  62. data/package.json +2 -1
  63. data/scripts/action-run.sh +51 -0
  64. data/scripts/build-rule-pages.mjs +87 -0
  65. data/scripts/check-rule-pages.mjs +18 -0
  66. data/scripts/pre-commit +24 -0
  67. data/sig/breadkit/lint.rbs +12 -1
  68. data/site/index.html +9 -6
  69. data/site/schemas/lint-v1.json +71 -0
  70. data/site/styles.css +13 -0
  71. metadata +64 -4
@@ -7,19 +7,33 @@ module Breadkit
7
7
  circuit.nets(state).filter_map do |net|
8
8
  next unless net.labels.length > 1
9
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)
10
+ location = circuit.labels.find { |label| net.labels.include?(label.name) }&.location
11
+ offense(rule.id, message, location, targets: { nets: [net.name] }, state: state.name)
11
12
  end
12
13
  end
13
14
 
14
15
  def expectations(circuit, rule, state)
15
16
  return [] if rule.id == "Intent/UnknownNet" && circuit.diagnostics.any? { |item| item.code == "unknown_net" }
16
17
  circuit.expectations.flat_map do |expectation|
18
+ at_state = expectation["when"] || expectation[:when]
19
+ if at_state
20
+ unless @config.intent_states(circuit, [expectation]).any? { |candidate| candidate.name == at_state }
21
+ next [] unless state.name.nil? && rule.id == "Intent/ConnectionMismatch"
22
+ next [offense(rule.id, translate("unknown_state", "unknown circuit state #{at_state}", state: at_state),
23
+ location_from(expectation["location"] || expectation[:location]), targets: {})]
24
+ end
25
+ next [] unless state.name == at_state
26
+ else
27
+ next [] unless state.name.nil?
28
+ end
17
29
  strict = expectation["strict"] || expectation[:strict]
18
- Array(expectation["entries"] || expectation[:entries]).filter_map do |item|
30
+ entries = Array(expectation["entries"] || expectation[:entries])
31
+ results = entries.filter_map do |item|
19
32
  kind = item["kind"] || item[:kind]
20
33
  refs = item["refs"] || item[:refs] || []
21
34
  expected_name = item["name"] || item[:name]
22
35
  loc = item["location"] || item[:location]
36
+ next measured_expectation(circuit, rule, state, item) if %w[voltage current].include?(kind)
23
37
  names = refs.map(&:to_s)
24
38
  nets = names.empty? && kind == "net" ? [circuit.net_of(expected_name, state)] : names.map { |reference| circuit.net_of(reference, state) }
25
39
  if nets.any?(&:nil?)
@@ -38,20 +52,71 @@ module Breadkit
38
52
  circuit.components.keys.any? { |ref| member.start_with?("#{ref}.") }
39
53
  end.uniq
40
54
  declared_pins = names.map { |name| canonical_pin(circuit, name) }
41
- extra_pins = strict ? actual_pins - declared_pins : []
55
+ extra_pins = strict ? (actual_pins - declared_pins).sort : []
42
56
  name_matches = nets.first.name == expected_name.to_s
43
57
  next if nets.map(&:name).uniq.length == 1 && extra_pins.empty? && name_matches
44
58
  detail = if !extra_pins.empty?
45
59
  translate("undeclared_pins", "undeclared pins on expected net: #{extra_pins.join(', ')}", pins: extra_pins.join(", "))
46
60
  elsif !name_matches
47
- translate("expected_net_name", "expected net #{expected_name}, found #{nets.first.name}", expected: expected_name, actual: nets.first.name)
61
+ translate("expected_net_name", "expected net #{expected_name}, found #{nets.first.name}; add net :#{expected_name}, at: ... to label it", expected: expected_name, actual: nets.first.name)
48
62
  else
49
63
  translate("expected_one_net", "expected #{names.join(', ')} on one net", refs: names.join(", "))
50
64
  end
51
65
  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
66
  end
53
67
  end
68
+ if strict && rule.id == "Intent/ConnectionMismatch"
69
+ declared_names = entries.select { |item| (item["kind"] || item[:kind]) == "net" }.flat_map do |item|
70
+ Array(item["refs"] || item[:refs]).filter_map { |ref| circuit.net_of(ref, state)&.name }
71
+ end.uniq
72
+ outside_nets = circuit.nets(state).filter_map do |net|
73
+ next if declared_names.include?(net.name)
74
+ pins = net.members.select { |member| circuit.components.key?(member.split(".", 2).first) }.sort
75
+ next if pins.empty?
76
+ [net, pins]
77
+ end
78
+ outside_nets.sort_by { |_net, pins| pins.first }.each do |net, pins|
79
+ message = translate("undeclared_pins", "undeclared pins outside expected nets: #{pins.join(', ')}", pins: pins.join(", "))
80
+ first_net_entry = entries.find { |item| (item["kind"] || item[:kind]) == "net" }
81
+ location = first_net_entry && (first_net_entry["location"] || first_net_entry[:location])
82
+ results << offense(rule.id, message, location_from(location || expectation["location"] || expectation[:location]),
83
+ targets: { nets: [net.name], components: pins.map { |pin| pin.split(".", 2).first }.uniq,
84
+ pins: pins }, state: state.name)
85
+ end
86
+ end
87
+ results
88
+ end
89
+ end
90
+
91
+ def measured_expectation(circuit, rule, state, item)
92
+ return unless %w[Intent/ConnectionMismatch Intent/MeasurementUnavailable].include?(rule.id)
93
+
94
+ kind = item["kind"] || item[:kind]
95
+ reference = Array(item["refs"] || item[:refs]).first.to_s
96
+ range = item["range"] || item[:range]
97
+ location = location_from(item["location"] || item[:location])
98
+ analysis = circuit.dc_analysis(state)
99
+ if analysis.success?
100
+ net = circuit.net_of(reference, state) if kind == "voltage"
101
+ value = kind == "voltage" ? analysis.voltages[net&.name] : analysis.currents[reference]&.abs
102
+ floating = kind == "voltage" && analysis.floating.any? { |group| group.include?(net&.name) }
103
+ reason = "no grounded DC value" if value.nil? || floating
104
+ else
105
+ reason = analysis.errors.join("; ")
54
106
  end
107
+ if reason
108
+ return unless rule.id == "Intent/MeasurementUnavailable"
109
+
110
+ message = translate("measurement_unavailable", "cannot verify #{kind} at #{reference}: #{reason}",
111
+ kind: kind, ref: reference, reason: reason)
112
+ return offense(rule.id, message, location, state: state.name)
113
+ end
114
+ return unless rule.id == "Intent/ConnectionMismatch" && (value < range[0] - 1e-9 || value > range[1] + 1e-9)
115
+
116
+ unit = kind == "voltage" ? "V" : "A"
117
+ message = translate("measurement_mismatch", "expected #{reference} #{kind} #{range.join('..')} #{unit}, found #{format('%.6g', value)} #{unit}",
118
+ ref: reference, kind: kind, range: range.join(".."), value: format("%.6g", value), unit: unit)
119
+ offense(rule.id, message, location, state: state.name)
55
120
  end
56
121
 
57
122
  end
@@ -28,6 +28,154 @@ module Breadkit
28
28
  end
29
29
  end
30
30
 
31
+ def hole_covered(circuit, rule, _state)
32
+ circuit.components.values.flat_map do |owner|
33
+ bounds = body_rectangle(owner, circuit.board)
34
+ next [] unless bounds
35
+ left, top, width, height = bounds
36
+ inside = lambda do |hole|
37
+ hole && hole.x > left + 1e-6 && hole.x < left + width - 1e-6 &&
38
+ hole.y > top + 1e-6 && hole.y < top + height - 1e-6
39
+ end
40
+ leads = circuit.components.values.reject { |component| component.equal?(owner) }.flat_map do |component|
41
+ component.pins.values.filter_map do |pin|
42
+ hole = pin.hole_id && circuit.board.hole(pin.hole_id)
43
+ next unless inside.call(hole)
44
+ reference = "#{component.ref}.#{pin.name}"
45
+ offense(rule.id, translate("hole_covered_lead", "#{reference} is under #{owner.ref} at #{hole.id}",
46
+ pin: reference, module: owner.ref, hole: hole.id), component.location,
47
+ targets: { components: [owner.ref, component.ref], pins: [reference], holes: [hole.id] })
48
+ end
49
+ end
50
+ wires = circuit.wires.flat_map do |wire|
51
+ next [] if wire.electrical == false
52
+ [wire.from, wire.to].uniq.filter_map do |endpoint|
53
+ hole = circuit.board.hole(endpoint)
54
+ next unless inside.call(hole)
55
+ offense(rule.id, translate("hole_covered_wire", "#{wire.id} ends under #{owner.ref} at #{hole.id}",
56
+ wire: wire.id, module: owner.ref, hole: hole.id), wire.location,
57
+ targets: { components: [owner.ref], wires: [wire.id], holes: [hole.id] })
58
+ end
59
+ end
60
+ leads + wires
61
+ end
62
+ end
63
+
64
+ def body_overlap(circuit, rule, _state)
65
+ bodies = circuit.components.values.filter_map do |component|
66
+ body = physical_body(component, circuit.board)
67
+ [component, body] if body
68
+ end
69
+ bodies.combination(2).filter_map do |(first, a), (second, b)|
70
+ next unless bodies_overlap?(a, b)
71
+ offense(rule.id, translate("body_overlap", "#{first.ref} and #{second.ref} bodies overlap",
72
+ first: first.ref, second: second.ref), second.location,
73
+ targets: { components: [first.ref, second.ref] })
74
+ end
75
+ end
76
+
77
+ def wire_over_ic(circuit, rule, _state)
78
+ circuit.components.values.flat_map do |component|
79
+ bounds = dip_body_bounds(component, circuit.board)
80
+ next [] unless bounds
81
+ circuit.wires.filter_map do |wire|
82
+ next unless wire.electrical != false && wire.route == "straight"
83
+ from = circuit.board.hole(wire.from)
84
+ to = circuit.board.hole(wire.to)
85
+ next unless from && to
86
+ next unless segment_crosses_rect?(from, to, bounds)
87
+ offense(rule.id, translate("wire_over_ic", "#{wire.id} crosses #{component.ref} body",
88
+ wire: wire.id, ic: component.ref), wire.location,
89
+ targets: { components: [component.ref], wires: [wire.id] })
90
+ end
91
+ end
92
+ end
93
+
94
+ def lead_span(circuit, rule, _state)
95
+ circuit.components.values.filter_map do |component|
96
+ part = component.part
97
+ next unless part.respond_to?(:max_lead_span_mm)
98
+ limit = part.max_lead_span_mm
99
+ next unless limit
100
+ pins = component.pins.values
101
+ next unless pins.length == 2
102
+ holes = pins.map { |pin| pin.hole_id && circuit.board.hole(pin.hole_id) }
103
+ next unless holes.all?
104
+ actual = Math.hypot(holes[0].x - holes[1].x, holes[0].y - holes[1].y) * 2.54
105
+ next unless actual > limit + 1e-6
106
+ actual_text, limit_text = format("%.3f", actual), format("%.3f", limit)
107
+ offense(rule.id, translate("lead_span", "#{component.ref} spans #{actual_text} mm; limit is #{limit_text} mm",
108
+ ref: component.ref, actual: actual_text, limit: limit_text),
109
+ component.location,
110
+ targets: { components: [component.ref], pins: pins.map { |pin| "#{component.ref}.#{pin.name}" }, holes: holes.map(&:id) })
111
+ end
112
+ end
113
+
114
+ def dip_body_bounds(component, board)
115
+ return unless component.part.placement == "dip" && component.part.data.dig("render", "shape") == "dip"
116
+ holes = component.pins.values.filter_map { |pin| board.hole(pin.hole_id) if pin.hole_id }
117
+ return unless holes.length == component.part.pins.length && holes.all? { |hole| hole.kind == :terminal }
118
+ xs, ys = holes.map(&:x), holes.map(&:y).uniq.sort
119
+ return unless ys.length == 2 && (ys.last - ys.first - 3).abs < 1e-6
120
+ rows = holes.group_by(&:y).values.map { |row| row.map(&:x).sort }
121
+ return unless rows[0] == rows[1] && rows[0].length * 2 == holes.length
122
+ [xs.min - 0.4, (ys.first + ys.last) / 2.0 - 0.4, xs.max - xs.min + 0.8, 0.8]
123
+ end
124
+
125
+ def segment_crosses_rect?(from, to, bounds)
126
+ left, top, width, height = bounds
127
+ ranges = [[left + 1e-6, left + width - 1e-6, from.x, to.x],
128
+ [top + 1e-6, top + height - 1e-6, from.y, to.y]]
129
+ entry, exit = 0.0, 1.0
130
+ ranges.each do |minimum, maximum, start, finish|
131
+ delta = finish - start
132
+ return false if delta.abs < 1e-9 && !(start > minimum && start < maximum)
133
+ next if delta.abs < 1e-9
134
+ first, last = [(minimum - start) / delta, (maximum - start) / delta].minmax
135
+ entry = [entry, first].max
136
+ exit = [exit, last].min
137
+ end
138
+ entry < exit - 1e-9
139
+ end
140
+
141
+ def physical_body(component, board)
142
+ shape = component.part.data.dig("render", "shape")
143
+ if %w[module dip].include?(shape)
144
+ bounds = body_rectangle(component, board)
145
+ return [:rect, *bounds] if bounds
146
+ elsif %w[led_5mm rgb_led_5mm].include?(shape)
147
+ holes = component.pins.values.filter_map { |pin| board.hole(pin.hole_id) if pin.hole_id }
148
+ return unless holes.length == component.pins.length && !holes.empty?
149
+ return [:circle, holes.sum(&:x) / holes.length, holes.sum(&:y) / holes.length, 2.5 / 2.54]
150
+ end
151
+ nil
152
+ end
153
+
154
+ def body_rectangle(component, board)
155
+ shape = component.part.data.dig("render", "shape")
156
+ return component.body_bounds(board) if shape == "module" && component.respond_to?(:body_bounds)
157
+ dip_body_bounds(component, board) if shape == "dip"
158
+ end
159
+
160
+ def bodies_overlap?(a, b)
161
+ if a.first == :rect && b.first == :rect
162
+ _kind, ax, ay, aw, ah = a
163
+ _kind, bx, by, bw, bh = b
164
+ return ax < bx + bw - 1e-6 && bx < ax + aw - 1e-6 &&
165
+ ay < by + bh - 1e-6 && by < ay + ah - 1e-6
166
+ end
167
+ if a.first == :circle && b.first == :circle
168
+ dx, dy = a[1] - b[1], a[2] - b[2]
169
+ return dx * dx + dy * dy < (a[3] + b[3] - 1e-6)**2
170
+ end
171
+ circle, rect = a.first == :circle ? [a, b] : [b, a]
172
+ _kind, cx, cy, radius = circle
173
+ _kind, rx, ry, width, height = rect
174
+ dx = cx - cx.clamp(rx, rx + width)
175
+ dy = cy - cy.clamp(ry, ry + height)
176
+ dx * dx + dy * dy < (radius - 1e-6)**2
177
+ end
178
+
31
179
  end
32
180
  end
33
181
  end
@@ -10,7 +10,8 @@ module Breadkit
10
10
  net = circuit.nets.find { |item| item.members.include?(wire.id) }
11
11
  voltage = net && circuit.potentials.values[net.name]
12
12
  expected = voltage&.positive? ? positive : (voltage == 0.0 ? ground : nil)
13
- next unless expected && wire.color && !expected.include?(color_family(wire.color))
13
+ family = color_family(wire.color) if wire.color
14
+ next unless expected && family && !expected.include?(family)
14
15
  message = translate("wire_color", "#{wire.id} uses #{wire.color}; expected #{expected.join(' or ')}",
15
16
  wire: wire.id, color: wire.color, expected: expected.join(" / "))
16
17
  offense(rule.id, message, wire.location,
@@ -20,10 +21,36 @@ module Breadkit
20
21
 
21
22
  def color_family(color)
22
23
  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) }
24
+ return "gray" if value.match?(/gr[ae]y|silver|white/)
25
+ return "black" if value == "black"
26
+ return "red" if value.match?(/red|crimson|maroon|firebrick|coral|tomato/)
27
+ return "orange" if value.match?(/orange|gold|salmon/)
28
+ return "blue" if value.match?(/blue|navy|azure|cyan|teal|turquoise/)
29
+ return "green" if value.match?(/green|lime|chartreuse|olive|spring|aquamarine/)
30
+ return "yellow" if value.match?(/yellow|khaki/)
31
+ return "purple" if value.match?(/purple|violet|indigo|orchid|plum|magenta|fuchsia/)
32
+ return "pink" if value.match?(/pink|lavender/)
33
+ return "brown" if value.match?(/brown|tan|chocolate|peru|sienna|wheat/)
34
+ if (match = /\Ahsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)\z/.match(value))
35
+ hue, saturation, lightness = match.captures.map(&:to_i)
36
+ return "black" if lightness <= 15
37
+ return "gray" if saturation < 25
38
+ return "red" if hue < 15 || hue >= 345
39
+ return "orange" if hue < 50
40
+ return "yellow" if hue < 75
41
+ return "green" if hue < 165
42
+ return "blue" if hue.between?(165, 255)
43
+ return "purple"
44
+ end
45
+ if (match = /\Argb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\z/.match(value))
46
+ red, green, blue = match.captures.map(&:to_i)
47
+ elsif value.match?(/\A#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\z/)
48
+ digits = value.delete_prefix("#")
49
+ digits = digits.chars.map { |digit| digit * 2 }.join if digits.length <= 4
50
+ red, green, blue = digits.scan(/../).first(3).map { |pair| pair.to_i(16) }
51
+ else
52
+ return nil
53
+ end
27
54
  max, min = [red, green, blue].max, [red, green, blue].min
28
55
  return "black" if max <= 76
29
56
  return "gray" if max == min || (max - min).fdiv(max) < 0.25
@@ -34,8 +61,10 @@ module Breadkit
34
61
  end % 360
35
62
  return "red" if hue < 15 || hue >= 345
36
63
  return "orange" if hue < 50
37
- return "blue" if hue.between?(195, 255)
38
- value
64
+ return "yellow" if hue < 75
65
+ return "green" if hue < 165
66
+ return "blue" if hue.between?(165, 255)
67
+ "purple"
39
68
  end
40
69
 
41
70
  end
@@ -22,18 +22,30 @@ module Breadkit
22
22
 
23
23
  def diagnostic_targets(circuit, values)
24
24
  ids = { components: circuit.components.keys, wires: circuit.wires.map(&:id),
25
- holes: circuit.board.holes.keys, nets: circuit.nets.map(&:name) }
25
+ holes: circuit.board.holes.keys,
26
+ pins: circuit.components.values.flat_map { |component| component.pins.values.map { |pin| "#{component.ref}.#{pin.name}" } },
27
+ nets: circuit.nets.map(&:name) }
26
28
  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
+ canonical = canonical_pin(circuit, value)
30
+ kind = ids.find { |_type, known| known.include?(canonical) }&.first
31
+ (targets[kind] ||= []) << canonical if kind
29
32
  end
30
33
  end
31
34
 
32
35
  def diagnostic_message(diagnostic)
33
36
  target = Array(diagnostic.targets).join(", ")
34
37
  target = diagnostic.message[/unknown pin ([^;]+)/, 1] || target if diagnostic.code == "unknown_pin"
38
+ target = diagnostic.message[/unknown board: (.+)/, 1] || target if diagnostic.code == "unknown_board"
35
39
  option = diagnostic.message[/unknown component option ([^ ]+)/, 1] if diagnostic.code == "unknown_option"
36
- translate("diagnostic_#{diagnostic.code}", diagnostic.message, target: target, option: option)
40
+ reason = case diagnostic.message
41
+ when /must straddle the center gap/ then translate("placement_straddle", "must straddle the center gap")
42
+ when /extends beyond the board or into the center gap/ then translate("placement_bounds_or_gap", "extends beyond the board or into the center gap")
43
+ when /extends beyond the board/ then translate("placement_bounds", "extends beyond the board")
44
+ when /pins do not match its footprint/ then translate("placement_footprint", "pins do not match the footprint")
45
+ when /needs a terminal hole anchor/ then translate("placement_anchor", "needs a terminal hole anchor")
46
+ else diagnostic.message
47
+ end
48
+ translate("diagnostic_#{diagnostic.code}", diagnostic.message, target: target, option: option, reason: reason)
37
49
  end
38
50
 
39
51
  def translate(key, english, **values)
@@ -41,35 +53,57 @@ module Breadkit
41
53
  template ? format(template, **values) : english
42
54
  end
43
55
 
44
- def offense(rule_id, message, location, targets: {}, state: nil)
56
+ def offense(rule_id, message, location, targets: {}, state: nil, suggestion: nil)
45
57
  rule = Registry.all.find { |item| item.id == rule_id }
46
58
  Offense.new(rule: rule_id, severity: rule ? @config.severity(rule) : "error", message: message,
47
- location: location, targets: targets, state: state)
59
+ location: location, targets: targets, state: state, suggestion: suggestion)
48
60
  end
49
61
 
50
- def suppress(offenses, disables, circuit)
62
+ def suppress(offenses, disables, circuit, path:, only: nil, except: nil, skipped: false)
51
63
  invalid_disables = []
52
64
  invalid = disables.filter_map do |disable|
53
65
  rule_id = disable[:rule] || disable["rule"]
54
66
  reason = disable[:reason] || disable["reason"]
55
- message = if Registry.all.none? { |rule| rule.id == rule_id }
67
+ unknown = Registry.all.none? { |rule| rule.id == rule_id }
68
+ message = if unknown
56
69
  translate("unknown_disable", "unknown rule in lint_disable: #{rule_id}", rule: rule_id)
57
70
  elsif @config.data.dig("AllRules", "RequireDisableReason") && reason.to_s.strip.empty?
58
71
  translate("disable_reason", "lint_disable for #{rule_id} requires a reason", rule: rule_id)
59
72
  end
60
73
  next unless message
61
74
  invalid_disables << disable
62
- offense("Config/InvalidDisable", message, location_from(disable[:location] || disable["location"]))
75
+ if unknown
76
+ rule = Rules::Lint::UnknownRuleInDisable
77
+ next if !@config.enabled?(rule) || !@config.rule_applies?(rule, path) || (only && !only.include?(rule.id)) || except&.include?(rule.id)
78
+ end
79
+ offense(unknown ? "Lint/UnknownRuleInDisable" : "Config/InvalidDisable", message,
80
+ location_from(disable[:location] || disable["location"]))
63
81
  end
64
82
  valid = disables - invalid_disables
83
+ used = []
65
84
  kept = offenses.reject do |item|
66
- valid.any? do |disable|
85
+ match = valid.find do |disable|
67
86
  rule = disable[:rule] || disable["rule"]
68
87
  target = disable[:on] || disable["on"]
69
88
  rule == item.rule && (!target || item.targets.values.flatten.include?(canonical_pin(circuit, target)))
70
89
  end
90
+ used << match if match
91
+ !!match
92
+ end
93
+ redundant_selected = (!only || only.include?("Lint/RedundantDisable")) && !except&.include?("Lint/RedundantDisable")
94
+ redundant = valid.filter_map do |disable|
95
+ next unless redundant_selected
96
+ rule_id = disable[:rule] || disable["rule"]
97
+ rule = Registry.all.find { |item| item.id == rule_id }
98
+ next if used.include?(disable) || !rule || !@config.enabled?(rule) || !@config.rule_applies?(rule, path)
99
+ next if only && !only.include?(rule_id)
100
+ next if except&.include?(rule_id)
101
+ next if skipped && rule_id.start_with?("Electrical/", "Intent/")
102
+ next unless @config.enabled?(Rules::Lint::RedundantDisable) && @config.rule_applies?(Rules::Lint::RedundantDisable, path)
103
+ offense("Lint/RedundantDisable", translate("redundant_disable", "lint_disable for #{rule_id} suppresses no offense", rule: rule_id),
104
+ location_from(disable[:location] || disable["location"]))
71
105
  end
72
- kept + invalid
106
+ (kept + invalid + redundant).sort_by { |item| [item.location&.path.to_s, item.location&.line.to_i, item.rule, item.message] }
73
107
  end
74
108
 
75
109
  def canonical_pin(circuit, reference)