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
pcba/review.py ADDED
@@ -0,0 +1,177 @@
1
+ """Review orchestration shared by the CLI and MCP server.
2
+
3
+ Discovery locates inputs, parsers build the normalized project, and rules evaluate it. Keeping
4
+ that sequence here prevents each interface from quietly assembling a different review.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ from pathlib import Path
11
+
12
+ from pcba import baseline as baseline_file
13
+ from pcba import changes as change_set
14
+ from pcba import config, discover, report, rules
15
+ from pcba.model.finding import Finding
16
+ from pcba.model.pinmap import Project
17
+
18
+
19
+ @dataclasses.dataclass(slots=True)
20
+ class ReviewResult:
21
+ discovery: discover.Discovery
22
+ project: Project
23
+ findings: list[Finding]
24
+ coverage: dict
25
+ baseline: baseline_file.AppliedBaseline | None = None
26
+ changes: change_set.AppliedChangeFilter | None = None
27
+
28
+ def render(self, fmt: str, *, findings: list[Finding] | None = None, sarif_root: bool = True) -> str:
29
+ """The report as `json`, `sarif`, `markdown` or `text`. The CLI, the review MCP server and
30
+ the browser worker all render here, so a new report field reaches every interface.
31
+
32
+ `findings` replaces the full list (the MCP server caps it). `sarif_root` declares the
33
+ project folder as `%SRCROOT%`; the browser has no folder worth naming."""
34
+ shown = self.findings if findings is None else findings
35
+ baseline_info = self.baseline.to_dict() if self.baseline else None
36
+ changes_info = self.changes.to_dict() if self.changes else None
37
+ if fmt == "json":
38
+ return report.to_json(shown, self.coverage, self.discovery.to_dict(),
39
+ baseline=baseline_info, changes=changes_info)
40
+ if fmt == "sarif":
41
+ return report.to_sarif(shown, root=self.discovery.root if sarif_root else None,
42
+ coverage=self.coverage, discovery=self.discovery.to_dict(),
43
+ baseline=baseline_info, changes=changes_info)
44
+ if fmt == "markdown":
45
+ return report.to_markdown(shown, self.coverage, baseline_info, changes_info)
46
+ if fmt == "text":
47
+ return report.to_text(shown, self.coverage, baseline_info, changes_info)
48
+ raise ValueError(f"unknown report format: {fmt}")
49
+
50
+
51
+ def explicit_path(root: Path, value: Path | str, label: str, *, directory: bool) -> Path:
52
+ """Resolve an input path the caller named, the same way for the CLI, MCP and the action.
53
+
54
+ A relative path is taken from the project root first, as `pcba-studio.toml` paths are;
55
+ the working directory is the fallback, so `enginehall review proj --ioc proj/board.ioc` keeps
56
+ working. A path the caller named explicitly and that does not exist is an error: a note
57
+ would let the review pass without the rules that input was given for.
58
+ """
59
+ path = Path(value).expanduser()
60
+ if not path.is_absolute():
61
+ in_project = root / path
62
+ if in_project.exists() or not path.exists():
63
+ path = in_project
64
+ path = path.resolve()
65
+ if not (path.is_dir() if directory else path.is_file()):
66
+ kind = "directory" if directory else "file"
67
+ raise config.ProjectConfigError(
68
+ f"{label} is not an existing {kind}: {value} (looked in {root} and the working "
69
+ "directory)"
70
+ )
71
+ return path
72
+
73
+
74
+ def load_project(
75
+ root: Path | str,
76
+ *,
77
+ part: str | None = None,
78
+ schematic_component: str | None = None,
79
+ ioc: Path | str | None = None,
80
+ schematic_dir: Path | str | None = None,
81
+ target: str | None = None,
82
+ kicad_project: Path | str | None = None,
83
+ tools: bool = True,
84
+ ) -> tuple[discover.Discovery, Project]:
85
+ """Discover and parse a project without evaluating rules.
86
+
87
+ `tools=False` skips kicad-cli and ngspice, which only the review needs."""
88
+ selection = config.select(root, target)
89
+ project_root = Path(root).resolve()
90
+ if ioc:
91
+ ioc = explicit_path(project_root, ioc, "explicit .ioc", directory=False)
92
+ if schematic_dir:
93
+ schematic_dir = explicit_path(
94
+ project_root, schematic_dir, "explicit schematic directory", directory=True
95
+ )
96
+ if kicad_project:
97
+ named = Path(kicad_project)
98
+ kicad_project = explicit_path(
99
+ project_root, kicad_project, "explicit KiCad project",
100
+ directory=not named.suffix or (project_root / named).is_dir() or named.is_dir(),
101
+ )
102
+ selected_part = part or (selection.part if selection else None)
103
+ selected_ioc = ioc or (selection.ioc if selection else None)
104
+ selected_schematic = schematic_dir or (selection.schematic_dir if selection else None)
105
+ selected_firmware = selection.firmware_roots if selection else None
106
+ selected_kicad = kicad_project or (selection.kicad_project if selection else None)
107
+ found = discover.discover(
108
+ Path(root),
109
+ ioc=Path(selected_ioc) if selected_ioc else None,
110
+ schematic_dir=Path(selected_schematic) if selected_schematic else None,
111
+ firmware_roots=selected_firmware,
112
+ kicad_project=Path(selected_kicad) if selected_kicad else None,
113
+ )
114
+ found.target = selection.name if selection else None
115
+ if selected_part:
116
+ found.part = selected_part
117
+ component = schematic_component or (selection.schematic_component if selection else None)
118
+ return found, discover.build_project(found, part=selected_part, component=component, tools=tools)
119
+
120
+
121
+ def run(
122
+ root: Path | str,
123
+ *,
124
+ part: str | None = None,
125
+ schematic_component: str | None = None,
126
+ ioc: Path | str | None = None,
127
+ schematic_dir: Path | str | None = None,
128
+ baseline: Path | str | None = None,
129
+ changed_only: str | None = None,
130
+ target: str | None = None,
131
+ required_rules: tuple[str, ...] = (),
132
+ kicad_project: Path | str | None = None,
133
+ ) -> ReviewResult:
134
+ """Run the complete deterministic review pipeline."""
135
+ found, project = load_project(
136
+ root, part=part, schematic_component=schematic_component, ioc=ioc,
137
+ schematic_dir=schematic_dir, target=target, kicad_project=kicad_project,
138
+ )
139
+ unknown = set(required_rules) - set(rules.REQUIREMENTS)
140
+ if unknown:
141
+ raise config.ProjectConfigError("unknown required rules: " + ", ".join(sorted(unknown)))
142
+ coverage = rules.coverage(project)
143
+ if required_rules:
144
+ unmet = set(required_rules) - set(coverage["ran"])
145
+ if "cross_domain.pin.mismatch" in required_rules and not coverage["statistics"]["compared_signals"]:
146
+ unmet.add("cross_domain.pin.mismatch")
147
+ coverage.update(required=sorted(set(required_rules)), unmet=sorted(unmet))
148
+ findings = rules.run(project)
149
+ coverage["rules"] = rules.rule_status(project, coverage, findings)
150
+ applied = None
151
+ if baseline:
152
+ findings, applied = baseline_file.apply(
153
+ findings, baseline_file.load(baseline), root=found.root
154
+ )
155
+ applied_changes = None
156
+ if changed_only:
157
+ findings, applied_changes = change_set.apply(findings, found.root, changed_only)
158
+ return ReviewResult(
159
+ discovery=found,
160
+ project=project,
161
+ findings=findings,
162
+ coverage=coverage,
163
+ baseline=applied,
164
+ changes=applied_changes,
165
+ )
166
+
167
+
168
+ def summary(found: discover.Discovery, project: Project) -> dict:
169
+ """Payload shared by `enginehall inspect --format json` and the MCP inspect tool."""
170
+ return {
171
+ **found.to_dict(),
172
+ "schematic_pins": len(project.schematic.pins) if project.schematic else 0,
173
+ "ioc_pins": len(project.mcu.assignments) if project.mcu else 0,
174
+ "ioc_peripherals": list(project.mcu.peripherals) if project.mcu else [],
175
+ "firmware_references": len(project.firmware.references) if project.firmware else 0,
176
+ "coverage": rules.coverage(project),
177
+ }
pcba/rules/__init__.py ADDED
@@ -0,0 +1,312 @@
1
+ """The rule registry.
2
+
3
+ A rule is a plain function `(Project) -> Iterator[Finding]`. No base class, no plugin
4
+ manifest, no declarative DSL — those are worth adding once the model has stopped moving, and
5
+ it has not.
6
+
7
+ Rules must degrade honestly. A rule whose inputs are missing yields nothing; it never
8
+ guesses, and it never reports a pass it did not earn. `coverage()` exists so the CLI can
9
+ tell the user which checks did not run, because a green result from a check that never
10
+ executed is the worst output this tool could produce.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable, Iterator
16
+ from dataclasses import replace
17
+
18
+ from pcba.model.finding import Detector, Finding
19
+ from pcba.model.pinmap import Project, Resolution
20
+ from pcba.rules import cross_domain, kicad_board, kicad_components, kicad_schematic, schematic, spice, stm32
21
+
22
+ Rule = Callable[[Project], Iterator[Finding]]
23
+
24
+ ALL_RULES: tuple[Rule, ...] = (schematic.RULES + stm32.RULES + cross_domain.RULES + kicad_schematic.RULES
25
+ + kicad_components.RULES + kicad_board.RULES + spice.RULES)
26
+
27
+ # What each rule family needs in order to say anything at all.
28
+ REQUIREMENTS: dict[str, tuple[str, ...]] = {
29
+ "cross_domain.mcu.part_mismatch": ("mcu", "schematic_identity"),
30
+ "schematic.net.mislabelled": ("schematic",),
31
+ "schematic.net.open_circuit": ("schematic",),
32
+ "stm32.pin.alternate_function_impossible": ("schematic", "mcu_database", "matching_part"),
33
+ "cross_domain.pin.mismatch": ("schematic", "mcu", "matching_part"),
34
+ "cross_domain.pin.signal_conflict": ("schematic", "mcu", "matching_part"),
35
+ "cross_domain.pin.unconfigured": ("schematic", "mcu", "matching_part"),
36
+ "cross_domain.mcu_config.incomplete": ("schematic", "mcu", "matching_part"),
37
+ "cross_domain.peripheral.unconnected": ("schematic", "mcu", "matching_part"),
38
+ "cross_domain.firmware.hardcoded_pin_drift": ("firmware", "mcu"),
39
+ # KiCad schematic
40
+ "kicad.erc.violation": ("kicad_schematic", "kicad_cli_erc"),
41
+ "kicad.netlist.parity": ("kicad_schematic", "kicad_cli_netlist"),
42
+ "kicad.power.rail_without_source": ("kicad_schematic",),
43
+ "kicad.power.pin_without_dc_path": ("kicad_schematic",),
44
+ "kicad.power.decoupling_missing": ("kicad_schematic",),
45
+ "kicad.part.shorted": ("kicad_schematic",),
46
+ "kicad.pin.input_floating": ("kicad_schematic",),
47
+ "kicad.net.pull_up_missing": ("kicad_schematic",),
48
+ "kicad.label.global_single_sheet": ("kicad_schematic",),
49
+ "kicad.label.single_connection": ("kicad_schematic",),
50
+ "kicad.net.name_hygiene": ("kicad_schematic",),
51
+ # KiCad parts against component data and the requirements file
52
+ "kicad.component.supply_range": ("kicad_schematic",),
53
+ "kicad.component.capacitor_derating": ("kicad_schematic",),
54
+ "kicad.component.resistor_power": ("kicad_schematic",),
55
+ "kicad.component.logic_level": ("kicad_schematic",),
56
+ "kicad.component.required_connection": ("kicad_schematic",),
57
+ "kicad.connector.pin_assignment": ("kicad_schematic", "connector_specs"),
58
+ # KiCad board
59
+ "kicad.drc.violation": ("kicad_board", "kicad_cli_drc"),
60
+ "kicad.drc.schematic_parity": ("kicad_board", "kicad_schematic", "kicad_cli_drc"),
61
+ "kicad.dfm.fiducials": ("kicad_board",),
62
+ "kicad.dfm.track_angle": ("kicad_board",),
63
+ "kicad.dfm.via_in_pad": ("kicad_board",),
64
+ "kicad.dfm.via_spacing": ("kicad_board",),
65
+ "kicad.dfm.model_missing": ("kicad_board",),
66
+ "kicad.dfm.silkscreen_outside": ("kicad_board", "board_outline"),
67
+ "kicad.dfm.barcode_field": ("kicad_board",),
68
+ "kicad.dfm.bottom_side_smd": ("kicad_board", "single_sided_assembly"),
69
+ "kicad.dfm.smd_edge_clearance": ("kicad_board", "board_outline", "smd_edge_requirement"),
70
+ "kicad.dfm.test_point_coverage": ("kicad_board", "test_points_enabled"),
71
+ "kicad.dfm.courtyard_overlap": ("kicad_board",),
72
+ # Simulation
73
+ "simulation.spice.testbench": ("kicad_project", "simulations", "ngspice"),
74
+ }
75
+
76
+ # Rules written for one schematic format. On a project of the other format they are "not
77
+ # applicable" (a different statement from "not checked"): KiCad rules need a KiCad project, and
78
+ # the Altium open-circuit heuristic needs Altium sheets.
79
+ FORMATS: dict[str, str] = {rule_id: "kicad" for rule_id in REQUIREMENTS
80
+ if rule_id.startswith(("kicad.", "simulation."))}
81
+ FORMATS["schematic.net.open_circuit"] = "altium"
82
+
83
+ # Rules that know how many items they could judge, and which they could not (a part without
84
+ # component data, a capacitor without a rating). `rule_status` reports a rule that judged
85
+ # nothing as not checked, and lists the items it skipped.
86
+ SCOPE: dict[str, Callable[[Project], tuple[int, list[str]]]] = {
87
+ **kicad_schematic.SCOPE, **kicad_components.SCOPE, **kicad_board.SCOPE, **spice.SCOPE,
88
+ }
89
+
90
+
91
+ def run(project: Project, rules: tuple[Rule, ...] = ALL_RULES) -> list[Finding]:
92
+ """Run every rule and return findings, worst first."""
93
+ order = {"error": 0, "warning": 1, "info": 2}
94
+ findings: list[Finding] = []
95
+ for rule in rules:
96
+ for finding in rule(project):
97
+ if project.schematic and any(
98
+ p.resolution == Resolution.SERIES_R
99
+ and finding.location.artifact == project.relative(p.sheet)
100
+ and finding.location.pin == p.port_pin
101
+ for p in project.schematic.pins.values()
102
+ ):
103
+ finding = replace(finding, detector=Detector.HEURISTIC,
104
+ confidence=min(finding.confidence, 0.8))
105
+ findings.append(finding)
106
+ return sorted(
107
+ findings,
108
+ key=lambda f: (order.get(str(f.severity), 9), f.rule_id, f.location.describe()),
109
+ )
110
+
111
+
112
+ def _external(project: Project, name: str) -> dict:
113
+ return (project.design.external.get(name) or {}) if project.design else {}
114
+
115
+
116
+ def _ran(project: Project, name: str) -> bool:
117
+ """The tool ran (a review), or would run (`inspect` marks it planned without running it)."""
118
+ run = _external(project, name)
119
+ return bool(run.get("ran") or run.get("planned"))
120
+
121
+
122
+ def _availability(project: Project) -> tuple[dict[str, bool], dict[str, str]]:
123
+ """What each requirement token needs, and a sentence for when it is missing."""
124
+ design = project.design
125
+ requirements = design.requirements if design else None
126
+ board = design.board if design else None
127
+ available = {
128
+ "schematic": project.schematic is not None,
129
+ "schematic_identity": project.schematic is not None or project.part_conflict is not None,
130
+ "matching_part": project.part_conflict is None,
131
+ "mcu": project.mcu is not None,
132
+ "firmware": project.firmware is not None,
133
+ "mcu_database": stm32.af_table(project) is not None,
134
+ "kicad_project": design is not None,
135
+ "kicad_schematic": bool(design and design.schematic),
136
+ "kicad_board": board is not None,
137
+ "board_outline": bool(board and len(board.outline) >= 3),
138
+ "kicad_cli_erc": _ran(project, "erc"),
139
+ "kicad_cli_netlist": _ran(project, "netlist"),
140
+ "kicad_cli_drc": _ran(project, "drc"),
141
+ "connector_specs": bool(requirements and requirements.connectors),
142
+ "single_sided_assembly": bool(requirements and requirements.assembly == "single-sided"),
143
+ "smd_edge_requirement": bool(requirements and requirements.smd_edge_clearance_mm is not None),
144
+ "test_points_enabled": bool(requirements is None or requirements.test_points != "off"),
145
+ "simulations": bool(requirements and requirements.simulations),
146
+ "ngspice": _ran(project, "spice"),
147
+ }
148
+ reasons = {
149
+ "schematic": "no MCU pin map was extracted from the schematic",
150
+ "schematic_identity": "no schematic MCU could be identified",
151
+ "matching_part": "the schematic and the .ioc name different MCU parts",
152
+ "mcu": "no .ioc was read",
153
+ "firmware": "no firmware tree was read",
154
+ "mcu_database": "STM32CubeMX's MCU database was not found (PCBA_CUBEMX_DB)",
155
+ "kicad_project": "no KiCad project was found",
156
+ "kicad_schematic": "no KiCad schematic was read",
157
+ "kicad_board": "no .kicad_pcb was read",
158
+ "board_outline": "the board has no closed Edge.Cuts outline",
159
+ "kicad_cli_erc": _external(project, "erc").get("reason") or "kicad-cli did not run",
160
+ "kicad_cli_netlist": _external(project, "netlist").get("reason") or "kicad-cli did not run",
161
+ "kicad_cli_drc": _external(project, "drc").get("reason") or "kicad-cli did not run",
162
+ "connector_specs": "no [[connectors]] in pcba-requirements.toml",
163
+ "single_sided_assembly": 'pcba-requirements.toml does not declare board.assembly = "single-sided"',
164
+ "smd_edge_requirement": "no board.smd_edge_clearance_mm in pcba-requirements.toml",
165
+ "test_points_enabled": 'board.test_points = "off" in pcba-requirements.toml',
166
+ "simulations": "no [[simulations]] in pcba-requirements.toml",
167
+ "ngspice": _external(project, "spice").get("reason") or "ngspice did not run",
168
+ }
169
+ return available, reasons
170
+
171
+
172
+ def applicable(project: Project, rule_id: str) -> bool:
173
+ """Whether a rule concerns this project's schematic format at all."""
174
+ form = FORMATS.get(rule_id)
175
+ if form == "kicad":
176
+ return "kicad" in project.formats
177
+ if form == "altium":
178
+ return project.formats != ("kicad",)
179
+ return True
180
+
181
+
182
+ def coverage(project: Project) -> dict:
183
+ """Which rules could run, and which could not — with the reason.
184
+
185
+ Surfaced by the CLI so "0 findings" never gets confused with "nothing was checked".
186
+ """
187
+ available, reasons = _availability(project)
188
+ ran: list[str] = []
189
+ skipped: list[str] = []
190
+ not_applicable: list[str] = []
191
+ for rule_id, needs in REQUIREMENTS.items():
192
+ if not applicable(project, rule_id):
193
+ not_applicable.append(f"{rule_id} (applies to {FORMATS[rule_id]} projects)")
194
+ continue
195
+ missing = [n for n in needs if not available[n]]
196
+ if missing:
197
+ skipped.append(f"{rule_id} (no {', '.join(missing)})")
198
+ else:
199
+ ran.append(rule_id)
200
+ # The same signal sets pin_mismatch compares, so "compared" cannot overstate the rule.
201
+ schematic_pins = cross_domain.schematic_signal_pins(project) \
202
+ if project.schematic else {}
203
+ ioc_pins = cross_domain.ioc_signal_pins(project) \
204
+ if project.mcu else {}
205
+ schematic_signals = {s: len(p) for s, p in schematic_pins.items()}
206
+ ioc_signals = {s: len(p) for s, p in ioc_pins.items()}
207
+ comparable = {s for s, n in schematic_signals.items() if n == 1 and ioc_signals.get(s) == 1} \
208
+ if not project.part_conflict else set()
209
+ ambiguous = sorted(s for s in set(schematic_signals) | set(ioc_signals)
210
+ if schematic_signals.get(s, 0) > 1 or ioc_signals.get(s, 0) > 1)
211
+ # Each configured placement is compared by name when both sides name the signal, else
212
+ # on its pad (see cross_domain.pin_placements). Either counts; the reasons below are why
213
+ # neither was possible.
214
+ at_pad = {placed[0]: placed for placed in cross_domain.pin_placements(project)}
215
+ compared = 0
216
+ uncompared = []
217
+ for signal, placements in sorted(ioc_pins.items()):
218
+ for port_pin, assignment in placements:
219
+ placed = at_pad.get(port_pin)
220
+ status = placed[4] if placed else None
221
+ if signal in comparable or status in cross_domain.COMPARED_AT_PIN:
222
+ compared += 1
223
+ continue
224
+ detail = {"signal": signal, "ioc_pin": port_pin,
225
+ "ioc_key": assignment.signal_key or f"{port_pin}.Signal"}
226
+ if project.part_conflict:
227
+ detail["reason"] = "part_conflict"
228
+ elif not project.schematic:
229
+ detail["reason"] = "no_schematic"
230
+ else:
231
+ pin = placed[3] if placed else None
232
+ if pin is not None:
233
+ detail["schematic_artifact"] = project.relative(pin.sheet)
234
+ if pin.net:
235
+ detail["schematic_net"] = pin.net
236
+ detail["reason"] = status or "pin_not_in_schematic"
237
+ uncompared.append(detail)
238
+ statistics = {"schematic_signals": sum(schematic_signals.values()),
239
+ "ioc_signals": sum(ioc_signals.values()), "compared_signals": compared,
240
+ "ambiguous_signals": ambiguous, "uncompared_signals": uncompared}
241
+ out = {"ran": sorted(ran), "skipped": sorted(skipped), "statistics": statistics}
242
+ if not_applicable:
243
+ out["not_applicable"] = sorted(not_applicable)
244
+ if project.design is not None:
245
+ out["tools"] = _tools(project)
246
+ spice_results = _external(project, "spice").get("results")
247
+ if spice_results:
248
+ out["simulations"] = [
249
+ {k: v for k, v in r.items() if k in ("name", "status", "value", "reason", "measure", "pass", "warn")}
250
+ | {"netlist": project.relative(r["netlist"])} for r in spice_results]
251
+ return out
252
+
253
+
254
+ def _tools(project: Project) -> dict:
255
+ """Which external tools ran for this review, their versions, or why they did not."""
256
+ out = {}
257
+ for name in ("erc", "netlist", "drc", "spice"):
258
+ run = _external(project, name)
259
+ if not run:
260
+ continue
261
+ entry = {"ran": bool(run.get("ran"))}
262
+ for key in ("tool", "version", "reason"):
263
+ if run.get(key):
264
+ entry[key] = run[key]
265
+ out[name] = entry
266
+ return out
267
+
268
+
269
+ def rule_status(project: Project, coverage_data: dict, findings: list[Finding]) -> list[dict]:
270
+ """Every rule as passed, failed, info (only informational findings), not checked (with the
271
+ reason) or not applicable. A rule that ran but had nothing to judge is not checked, and the
272
+ items a rule skipped (a part without component data) are listed under `unchecked`.
273
+
274
+ Computed from the unfiltered findings, like the rest of coverage: a baseline hides findings,
275
+ it does not turn a failed rule into a passed one.
276
+ """
277
+ _available, reasons = _availability(project)
278
+ counts: dict[str, int] = {}
279
+ serious: set[str] = set()
280
+ for finding in findings:
281
+ counts[finding.rule_id] = counts.get(finding.rule_id, 0) + 1
282
+ if str(finding.severity) != "info":
283
+ serious.add(finding.rule_id)
284
+ skipped = {entry.split(" ")[0]: entry for entry in coverage_data.get("skipped", [])}
285
+ not_applicable = {entry.split(" ")[0] for entry in coverage_data.get("not_applicable", [])}
286
+ out = []
287
+ for rule_id, needs in sorted(REQUIREMENTS.items()):
288
+ entry: dict = {"rule_id": rule_id}
289
+ if rule_id in not_applicable:
290
+ entry.update(status="not_applicable", reason=f"applies to {FORMATS[rule_id]} projects only")
291
+ elif rule_id in skipped:
292
+ missing = [n for n in needs if not _available[n]]
293
+ entry.update(status="not_checked", reason="; ".join(reasons[n] for n in missing))
294
+ else:
295
+ checked, unchecked = SCOPE[rule_id](project) if rule_id in SCOPE else (None, [])
296
+ if counts.get(rule_id):
297
+ entry.update(status="failed" if rule_id in serious else "info", findings=counts[rule_id])
298
+ elif checked == 0:
299
+ entry.update(status="not_checked",
300
+ reason="nothing this rule could judge" + (f": {unchecked[0]}" if unchecked else ""))
301
+ elif (rule_id == "cross_domain.pin.mismatch"
302
+ and not coverage_data.get("statistics", {}).get("compared_signals")):
303
+ entry.update(status="not_checked", reason="no configured signal could be compared")
304
+ else:
305
+ entry.update(status="passed")
306
+ if unchecked:
307
+ entry["unchecked"] = unchecked
308
+ out.append(entry)
309
+ return out
310
+
311
+
312
+ __all__ = ["ALL_RULES", "FORMATS", "REQUIREMENTS", "Rule", "applicable", "coverage", "rule_status", "run"]