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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- 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"]
|