enginehall 2.0.0__py3-none-any.whl

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 (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
@@ -0,0 +1,496 @@
1
+ """Cross-domain rules — where the schematic, the MCU config and the firmware disagree.
2
+
3
+ This is the product. Every other rule here is a single-domain check that some other tool
4
+ also does; these are the ones nothing else does, because nothing else reads all three
5
+ artifacts at once.
6
+
7
+ Precedence: **the schematic wins.** The board is fabricated from it. If the `.ioc` and the
8
+ schematic disagree, the copper is already the way the schematic drew it and it is the
9
+ firmware configuration that is wrong — not the other way round.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterator
15
+
16
+ from pcba.model.finding import Domain, Evidence, Finding, Location, Severity
17
+ from pcba.model.pinmap import Project, Resolution, is_power_pin
18
+ from pcba.rules.locate import at_pin
19
+ from pcba.rules.signals import compatible, normalize, peripheral_instance
20
+ from pcba.rules import stm32
21
+
22
+
23
+ def mcu_part_mismatch(project: Project) -> Iterator[Finding]:
24
+ """The schematic and CubeMX name different silicon, so pin comparisons are unsafe."""
25
+ if not (project.part_conflict and project.mcu and project.mcu.part):
26
+ return
27
+ actual, sheet = project.part_conflict
28
+ ioc_source = project.relative(project.mcu.source)
29
+ sheet_source = project.relative(sheet)
30
+ key = "Mcu.UserName" if project.mcu.raw.get("Mcu.UserName") else "Mcu.Name"
31
+ yield Finding(
32
+ rule_id="cross_domain.mcu.part_mismatch",
33
+ title="The schematic and MCU configuration name different MCU parts",
34
+ severity=Severity.ERROR,
35
+ domain=Domain.CROSS_DOMAIN,
36
+ location=Location(artifact=ioc_source, key=key),
37
+ evidence=(
38
+ Evidence(kind="ioc_part", value=f"{key}={project.mcu.part}", source=ioc_source),
39
+ Evidence(kind="schematic_part", value=actual, source=sheet_source),
40
+ ),
41
+ explanation=(
42
+ f"The CubeMX configuration names {project.mcu.part}, while a schematic MCU symbol "
43
+ f"names {actual}. These parts are not orderable variants of the same identifier."
44
+ ),
45
+ consequence=(
46
+ "Pin and peripheral comparisons cannot be trusted across different MCU parts; "
47
+ "those checks were skipped for this review."
48
+ ),
49
+ recommendation=(
50
+ "Select the matching schematic and CubeMX configuration for this board revision, "
51
+ "or correct the MCU selection in CubeMX and regenerate the configuration."
52
+ ),
53
+ )
54
+
55
+
56
+ def schematic_signal_pins(project: Project) -> dict[str, list[tuple[str, object]]]:
57
+ """normalized signal -> every (pin, Pin) the schematic puts it on, by geometry."""
58
+ out: dict[str, list[tuple[str, object]]] = {}
59
+ for pin in project.schematic.pins.values():
60
+ if is_power_pin(pin.name) or pin.resolution in (Resolution.NONE, Resolution.AMBIGUOUS):
61
+ continue
62
+ signal = normalize(pin.net)
63
+ if signal:
64
+ out.setdefault(signal, []).append((pin.port_pin, pin))
65
+ return out
66
+
67
+
68
+ def ioc_signal_pins(project: Project) -> dict[str, list[tuple[str, object]]]:
69
+ """normalized signal -> every (pin, PinAssignment) the .ioc puts it on."""
70
+ out: dict[str, list[tuple[str, object]]] = {}
71
+ for pin, assignment in project.mcu.assignments.items():
72
+ signal = normalize(assignment.signal)
73
+ if signal:
74
+ out.setdefault(signal, []).append((pin, assignment))
75
+ return out
76
+
77
+
78
+ # What a configured placement found on the schematic pad it names. The first three are
79
+ # comparisons; the rest are why no comparison was possible.
80
+ COMPARED_AT_PIN = frozenset({"same_signal", "functional_net", "different_peripheral"})
81
+
82
+
83
+ def pin_placements(project: Project) -> list[tuple[str, object, str, object, str]]:
84
+ """Every peripheral placement in the .ioc, set against the schematic pin on that pad.
85
+
86
+ Returns (pin, PinAssignment, normalized signal, schematic Pin or None, status).
87
+
88
+ Comparing by name alone compares nothing on a board whose nets carry functional names
89
+ (RMII_TXD0, WIFI_SPI_SCK, HSE_IN): neither side names the other's signal. Comparing by
90
+ pad does not depend on naming. A net that names a compatible peripheral signal agrees
91
+ (`same_signal`); a functional name is accepted as the wire for whatever the pad is
92
+ configured as (`functional_net`); a net naming a different peripheral disagrees
93
+ (`different_peripheral`).
94
+ """
95
+ if not (project.schematic and project.mcu) or project.part_conflict:
96
+ return []
97
+ by_pin = project.schematic.by_pin_name()
98
+ out = []
99
+ for port_pin, assignment in sorted(project.mcu.assignments.items()):
100
+ signal = normalize(assignment.signal)
101
+ if not signal:
102
+ continue
103
+ pin = by_pin.get(port_pin)
104
+ if pin is None:
105
+ status = "pin_not_in_schematic"
106
+ elif pin.resolution == Resolution.AMBIGUOUS:
107
+ status = "ambiguous_net"
108
+ elif not pin.net:
109
+ status = "unnamed_connection" if pin.connected else "unconnected_pin"
110
+ elif normalize(pin.net) == signal or compatible(pin.net, signal):
111
+ status = "same_signal"
112
+ elif peripheral_instance(pin.net):
113
+ status = "different_peripheral"
114
+ else:
115
+ status = "functional_net"
116
+ out.append((port_pin, assignment, signal, pin, status))
117
+ return out
118
+
119
+
120
+ # A signal on several pins of one side has no single pin to compare. It is left out of the
121
+ # comparison, never compared first-wins, and coverage lists it as ambiguous.
122
+ def _schematic_signals(project: Project) -> dict[str, tuple[str, object]]:
123
+ return {s: pins[0] for s, pins in schematic_signal_pins(project).items() if len(pins) == 1}
124
+
125
+
126
+ def _ioc_signals(project: Project) -> dict[str, tuple[str, object]]:
127
+ return {s: pins[0] for s, pins in ioc_signal_pins(project).items() if len(pins) == 1}
128
+
129
+
130
+ def pin_mismatch(project: Project) -> Iterator[Finding]:
131
+ """The headline rule: the same signal on two different pins.
132
+
133
+ The schematic routes USART3_TX to PD8; the .ioc assigns it to PB10. One of them is
134
+ wrong, the board will be built from the schematic, and today nothing tells you before
135
+ the boards arrive.
136
+
137
+ Only signals that BOTH sides name are compared, so a GPIO the schematic calls
138
+ SENSOR_ENABLE and the .ioc leaves unlabelled produces nothing rather than a guess.
139
+ """
140
+ if not (project.schematic and project.mcu) or project.part_conflict:
141
+ return
142
+
143
+ schematic = _schematic_signals(project)
144
+ ioc = _ioc_signals(project)
145
+ ioc_source = project.relative(project.mcu.source)
146
+
147
+ for signal in sorted(set(schematic) & set(ioc)):
148
+ sch_pin, sch = schematic[signal]
149
+ ioc_pin, _ = ioc[signal]
150
+ if sch_pin == ioc_pin:
151
+ continue
152
+ yield Finding(
153
+ rule_id="cross_domain.pin.mismatch",
154
+ title=f"{signal} is on a different pin in the schematic than in the MCU config",
155
+ severity=Severity.ERROR,
156
+ domain=Domain.CROSS_DOMAIN,
157
+ location=at_pin(project, sch),
158
+ evidence=(
159
+ Evidence(
160
+ kind="schematic_net",
161
+ value=f"{sch_pin} (package pin {sch.package_pin}) carries net {sch.net!r}",
162
+ source=project.relative(sch.sheet),
163
+ ),
164
+ Evidence(
165
+ kind="ioc_assignment",
166
+ value=f"{ioc_pin}.Signal = {signal}",
167
+ source=ioc_source,
168
+ ),
169
+ ),
170
+ explanation=(
171
+ f"The schematic routes {signal} to {sch_pin}, resolved from wire geometry. "
172
+ f"The MCU configuration assigns {signal} to {ioc_pin}."
173
+ ),
174
+ consequence=(
175
+ f"The board is fabricated from the schematic, so the signal will be on "
176
+ f"{sch_pin}. Firmware configured for {ioc_pin} will drive the wrong pad and "
177
+ f"the interface will not work on assembled hardware."
178
+ ),
179
+ recommendation=(
180
+ f"Decide which pin is intended. If the schematic is right, move {signal} to "
181
+ f"{sch_pin} in CubeMX and regenerate. If the config is right, the schematic "
182
+ f"needs an ECO before layout."
183
+ ),
184
+ )
185
+
186
+
187
+ def pin_signal_conflict(project: Project) -> Iterator[Finding]:
188
+ """The schematic net on a pad names a different peripheral than the .ioc puts there.
189
+
190
+ The pad comparison's disagreeing case: PE6 carries net SPI2_MOSI while the .ioc gives
191
+ PE6 to I2C1_SDA. A net whose signal the .ioc places on another pin is left to
192
+ `pin_mismatch`, which already names both pins.
193
+ """
194
+ placements = pin_placements(project)
195
+ if not placements:
196
+ return
197
+ # Exactly the cases pin_mismatch reports: the net's signal on one pin of each side, and
198
+ # not the same pin. Anything else (a signal on several pins of a side) it skips, so the
199
+ # pad finding must stand.
200
+ schematic, ioc = _schematic_signals(project), _ioc_signals(project)
201
+ ioc_source = project.relative(project.mcu.source)
202
+ for port_pin, assignment, signal, pin, status in placements:
203
+ if status != "different_peripheral":
204
+ continue
205
+ net = normalize(pin.net)
206
+ if net in schematic and net in ioc and schematic[net][0] != ioc[net][0]:
207
+ continue
208
+ yield Finding(
209
+ rule_id="cross_domain.pin.signal_conflict",
210
+ title=f"{port_pin} carries net {pin.net} but the MCU config gives it to {signal}",
211
+ severity=Severity.WARNING,
212
+ domain=Domain.CROSS_DOMAIN,
213
+ location=at_pin(project, pin),
214
+ evidence=(
215
+ Evidence(
216
+ kind="schematic_net",
217
+ value=f"{port_pin} (package pin {pin.package_pin}) carries net {pin.net!r}",
218
+ source=project.relative(pin.sheet),
219
+ ),
220
+ Evidence(
221
+ kind="ioc_assignment",
222
+ value=f"{assignment.signal_key or port_pin + '.Signal'} = "
223
+ f"{assignment.signal}",
224
+ source=ioc_source,
225
+ ),
226
+ ),
227
+ explanation=(
228
+ f"The net on {port_pin} names {normalize(pin.net)}, a different peripheral "
229
+ f"signal than the {assignment.signal} the MCU configuration assigns to it."
230
+ ),
231
+ consequence=(
232
+ "Either the net label is wrong, or the firmware drives this pad as the wrong "
233
+ "peripheral and the interface will not work on the assembled board."
234
+ ),
235
+ recommendation=(
236
+ f"Rename the net if {port_pin} really carries {assignment.signal}; otherwise "
237
+ f"assign {normalize(pin.net)} to {port_pin} in CubeMX and regenerate."
238
+ ),
239
+ )
240
+
241
+
242
+ # Above this fraction of the schematic's signals missing from the .ioc, the honest
243
+ # conclusion is not "N pins need configuring" but "this .ioc does not describe this board".
244
+ STUB_THRESHOLD = 0.5
245
+
246
+
247
+ def pin_unconfigured(project: Project) -> Iterator[Finding]:
248
+ """The schematic routes a peripheral signal to a pin the .ioc never configures.
249
+
250
+ Collapses when the `.ioc` is clearly not a peer of the schematic. Measured on a real
251
+ design whose `.ioc` was still a hand-authored placeholder, the per-pin form emitted 65
252
+ findings — all individually true, and collectively worthless. They did not say "65 pins
253
+ need attention"; they said "this .ioc is a stub", 65 times, and buried 9 real errors
254
+ while doing it. One finding that names the actual problem beats 65 that imply the wrong
255
+ one.
256
+ """
257
+ if not (project.schematic and project.mcu) or project.part_conflict:
258
+ return
259
+
260
+ ioc = _ioc_signals(project)
261
+ schematic = _schematic_signals(project)
262
+ table = stm32.af_table(project)
263
+
264
+ missing = []
265
+ for signal, (sch_pin, sch) in sorted(schematic.items()):
266
+ if signal in ioc:
267
+ continue
268
+ assignment = project.mcu.assignments.get(sch_pin)
269
+ if assignment and normalize(assignment.signal):
270
+ continue # the pin does something else; pin_mismatch or a human owns that
271
+ if table is not None and sch_pin in table and not table[sch_pin]:
272
+ # ST lists the pad with no configurable signal at all (OTG1_TXRTUNE, a
273
+ # calibration resistor pin). No .ioc can ever assign it, so it is never missing.
274
+ continue
275
+ missing.append((signal, sch_pin, sch))
276
+
277
+ if not missing:
278
+ return
279
+
280
+ mcu_name = project.mcu.part or "the MCU configuration"
281
+ ioc_source = project.relative(project.mcu.source)
282
+ if schematic and len(missing) / len(schematic) >= STUB_THRESHOLD:
283
+ names = ", ".join(s for s, _, _ in missing[:6])
284
+ yield Finding(
285
+ rule_id="cross_domain.mcu_config.incomplete",
286
+ title=(
287
+ f"The MCU configuration covers almost none of the schematic "
288
+ f"({len(schematic) - len(missing)} of {len(schematic)} signals)"
289
+ ),
290
+ severity=Severity.WARNING,
291
+ domain=Domain.CROSS_DOMAIN,
292
+ location=Location(artifact=ioc_source, key="Mcu"),
293
+ evidence=(
294
+ Evidence(
295
+ kind="schematic_net",
296
+ value=f"{len(schematic)} peripheral signals routed on the schematic",
297
+ source=project.relative(project.schematic.source),
298
+ ),
299
+ Evidence(
300
+ kind="ioc_assignment",
301
+ value=f"{len(missing)} of them are unassigned in {mcu_name}",
302
+ source=ioc_source,
303
+ ),
304
+ Evidence(kind="schematic_net", value=f"unconfigured: {names}, …"),
305
+ ),
306
+ explanation=(
307
+ f"{len(missing)} of {len(schematic)} peripheral signals on the schematic have "
308
+ f"no assignment in {project.mcu.source}. At that proportion the configuration "
309
+ "is a placeholder, targets a different board, or belongs to a different "
310
+ "revision — rather than having a large number of individually missing pins."
311
+ ),
312
+ consequence=(
313
+ "Per-pin cross-checks cannot say anything useful until the configuration "
314
+ "describes this board, so this review is not yet covering the MCU config."
315
+ ),
316
+ recommendation=(
317
+ "Point --ioc at the configuration for this board, or generate the pin "
318
+ "assignment from the schematic before relying on cross-domain review."
319
+ ),
320
+ )
321
+ return
322
+
323
+ for signal, sch_pin, sch in missing:
324
+ yield Finding(
325
+ rule_id="cross_domain.pin.unconfigured",
326
+ title=f"{signal} is wired on the schematic but not configured on the MCU",
327
+ severity=Severity.WARNING,
328
+ domain=Domain.CROSS_DOMAIN,
329
+ location=at_pin(project, sch),
330
+ evidence=(
331
+ Evidence(
332
+ kind="schematic_net",
333
+ value=f"{sch_pin} carries net {sch.net!r}",
334
+ source=project.relative(sch.sheet),
335
+ ),
336
+ Evidence(
337
+ kind="ioc_assignment",
338
+ value=f"{sch_pin} is unassigned in {mcu_name}",
339
+ source=ioc_source,
340
+ ),
341
+ ),
342
+ explanation=(
343
+ f"The schematic connects {signal} to {sch_pin}, but the MCU configuration "
344
+ f"leaves {sch_pin} unassigned."
345
+ ),
346
+ consequence=(
347
+ "The hardware exists but no firmware initialises it. The interface is dead "
348
+ "on the assembled board until the pin is configured."
349
+ ),
350
+ recommendation=(
351
+ f"Assign {sch_pin} to {signal} in CubeMX, or confirm the net is intentionally "
352
+ "unpopulated on this revision."
353
+ ),
354
+ )
355
+
356
+
357
+ def peripheral_unconnected(project: Project) -> Iterator[Finding]:
358
+ """The .ioc configures a peripheral with no corresponding schematic connection.
359
+
360
+ Scoped to peripherals that need pins. A peripheral configured with every one of its pins
361
+ physically unconnected is either a leftover from a copied project or a feature nobody wired.
362
+ A wire need not have a peripheral-style net label to count as a connection.
363
+ """
364
+ if not (project.schematic and project.mcu) or project.part_conflict:
365
+ return
366
+
367
+ schematic_families = {
368
+ signal.split("_")[0] for signal in _schematic_signals(project)
369
+ }
370
+ schematic_pins = project.schematic.by_pin_name()
371
+ ioc_by_family: dict[str, list[str]] = {}
372
+ for signal, (pin, _) in _ioc_signals(project).items():
373
+ ioc_by_family.setdefault(signal.split("_")[0], []).append(pin)
374
+ ioc_source = project.relative(project.mcu.source)
375
+
376
+ for family, pins in sorted(ioc_by_family.items()):
377
+ if family in schematic_families or any(
378
+ schematic_pins.get(pin) and schematic_pins[pin].connected for pin in pins
379
+ ):
380
+ continue
381
+ yield Finding(
382
+ rule_id="cross_domain.peripheral.unconnected",
383
+ title=f"{family} is configured on the MCU but not connected on the schematic",
384
+ severity=Severity.WARNING,
385
+ domain=Domain.CROSS_DOMAIN,
386
+ location=Location(artifact=ioc_source, key=family),
387
+ evidence=(
388
+ Evidence(
389
+ kind="ioc_assignment",
390
+ value=f"{family} configured on {', '.join(sorted(pins))}",
391
+ source=ioc_source,
392
+ ),
393
+ Evidence(
394
+ kind="schematic_net",
395
+ value=f"none of {family}'s configured pads touches a schematic connection",
396
+ source=project.relative(project.schematic.source),
397
+ ),
398
+ ),
399
+ explanation=(
400
+ f"The MCU configuration enables {family} and assigns pins to it, but none "
401
+ "of those pins touches a wire, component pin, or named net in the schematic."
402
+ ),
403
+ consequence=(
404
+ "Firmware will initialise a peripheral that goes nowhere. If those pads are "
405
+ "connected to something else on the board, driving them may conflict with it."
406
+ ),
407
+ recommendation=(
408
+ f"Remove {family} from the MCU configuration, or confirm the schematic is "
409
+ "incomplete for this revision."
410
+ ),
411
+ )
412
+
413
+
414
+ def firmware_hardcoded_pin_drift(project: Project) -> Iterator[Finding]:
415
+ """Hand-written firmware names a pin the MCU config assigns to something else.
416
+
417
+ This is the drift that survives a regeneration. CubeMX rewrites `main.h` when a pin
418
+ moves, so generated code follows automatically — but a USER CODE block that spelled out
419
+ `GPIOD, GPIO_PIN_8` by hand does not. It keeps driving the old pad, and it still
420
+ compiles.
421
+ """
422
+ if not (project.firmware and project.mcu):
423
+ return
424
+
425
+ ioc_source = project.relative(project.mcu.source)
426
+ for pin, references in sorted(project.firmware.pins().items()):
427
+ # Reading a pin does not drive it. A wrapper or a pin table is not recognised as a
428
+ # read, so it still counts: dropping it would hide the drift this rule exists for.
429
+ references = [r for r in references if r.operation != "read"]
430
+ if not references:
431
+ continue
432
+ assignment = project.mcu.assignments.get(pin)
433
+ signal = assignment.signal if assignment else None
434
+ # A pin configured as plain GPIO is exactly what hand-written code should be
435
+ # touching. Only a pin owned by a peripheral is a conflict.
436
+ if not normalize(signal):
437
+ continue
438
+ first = references[0]
439
+ # The first three references, then one more per file not yet named: --changed-only
440
+ # keeps a finding only when its evidence names a changed file.
441
+ shown = references[:3]
442
+ for r in references[3:]:
443
+ if r.path not in {s.path for s in shown}:
444
+ shown.append(r)
445
+ yield Finding(
446
+ rule_id="cross_domain.firmware.hardcoded_pin_drift",
447
+ title=f"Firmware drives {pin} directly, but the MCU config gives it to {signal}",
448
+ severity=Severity.ERROR,
449
+ domain=Domain.CROSS_DOMAIN,
450
+ location=Location(artifact=project.relative(first.path), pin=pin, line=first.line),
451
+ # The first reference changes when another file that sorts earlier names the pin;
452
+ # the pin and its .ioc key do not, so a baseline entry keeps matching.
453
+ identity=Location(
454
+ artifact=ioc_source, pin=pin, key=assignment.signal_key or f"{pin}.Signal"
455
+ ),
456
+ evidence=tuple(
457
+ Evidence(
458
+ kind="firmware_reference",
459
+ value=r.text,
460
+ source=f"{project.relative(r.path)}:{r.line}",
461
+ )
462
+ for r in shown
463
+ )
464
+ + (
465
+ Evidence(
466
+ kind="ioc_assignment",
467
+ value=f"{pin}.Signal = {signal}",
468
+ source=ioc_source,
469
+ ),
470
+ ),
471
+ explanation=(
472
+ f"{len(references)} hand-written reference(s) address {pin} directly as a "
473
+ f"GPIO, but the MCU configuration assigns {pin} to {signal}. Because the "
474
+ f"reference is hardcoded rather than using CubeMX's generated define, it did "
475
+ f"not move when the pin did."
476
+ ),
477
+ consequence=(
478
+ f"The code reconfigures or drives a pad owned by {signal}, corrupting that "
479
+ "peripheral at runtime. It compiles cleanly, so this only shows up on hardware."
480
+ ),
481
+ recommendation=(
482
+ f"Use the generated `<label>_GPIO_Port` / `<label>_Pin` defines instead of "
483
+ f"naming {pin} directly, or move {signal} off {pin} if the hand-written code "
484
+ "is correct."
485
+ ),
486
+ )
487
+
488
+
489
+ RULES = (
490
+ mcu_part_mismatch,
491
+ pin_mismatch,
492
+ pin_signal_conflict,
493
+ pin_unconfigured,
494
+ peripheral_unconnected,
495
+ firmware_hardcoded_pin_drift,
496
+ )