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/publish.py ADDED
@@ -0,0 +1,269 @@
1
+ """Publish a review report to a development project's check runs (`enginehall review --publish`).
2
+
3
+ Only the report leaves the machine: findings, their locations and evidence, and coverage. No
4
+ design file, firmware source or absolute local path is sent (principle 3): the project paths in
5
+ the report are made project-relative, and the local root and home folder are scrubbed from every
6
+ other string. Stdlib only, like the rest of the engine.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from dataclasses import dataclass
19
+ from pathlib import Path, PurePath
20
+
21
+ from pcba import __version__
22
+
23
+ DEFAULT_TOKEN_ENV = "PCBA_WORKSPACE_TOKEN"
24
+ MAX_BODY_BYTES = 8 * 1024 * 1024
25
+ TIMEOUT_SECONDS = 30
26
+ MAX_REF = 255
27
+
28
+ _KEY = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,9}$")
29
+ _SHA = re.compile(r"^[0-9a-fA-F]{7,64}$")
30
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
31
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
32
+ _UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
33
+ # `git@github.com:owner/name.git`, the scp-like form `git remote` reports for SSH clones.
34
+ _SCP_REMOTE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+):(?!//)([^\s]+)$")
35
+ MAX_REPOSITORY = 500
36
+ GIT_TIMEOUT_SECONDS = 5
37
+
38
+
39
+ class PublishError(Exception):
40
+ """A publish that could not be prepared or delivered; the CLI reports it with exit 2."""
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class Target:
45
+ project: str
46
+ url: str
47
+ token: str
48
+ commit: str | None
49
+ ref: str | None
50
+ source: str
51
+ repository: str | None = None
52
+
53
+ @property
54
+ def endpoint(self) -> str:
55
+ return f"{self.url}/api/p/{urllib.parse.quote(self.project)}/check-runs"
56
+
57
+
58
+ def _truthy(value: str | None) -> bool:
59
+ return (value or "").strip().lower() not in ("", "0", "false", "no")
60
+
61
+
62
+ def _origin(value: str) -> str:
63
+ url = value.strip().rstrip("/")
64
+ parsed = urllib.parse.urlsplit(url)
65
+ if parsed.scheme not in ("https", "http") or not parsed.hostname:
66
+ raise PublishError(f"--url must be an http(s) origin such as https://enginehall.com, not {value!r}")
67
+ if parsed.scheme == "http" and parsed.hostname not in _LOCAL_HOSTS:
68
+ raise PublishError("--url must use https (plain http is accepted only for localhost)")
69
+ if parsed.query or parsed.fragment or parsed.username or parsed.password:
70
+ raise PublishError("--url must be the workspace origin, without credentials, query or fragment")
71
+ return url
72
+
73
+
74
+ def repository_url(value: str | None) -> str | None:
75
+ """A repository id, or a clone URL as https without credentials, `.git` or query; None if unusable.
76
+
77
+ `git@host:owner/name.git`, `ssh://git@host:22/owner/name` and `https://user:token@host/owner/name`
78
+ all become `https://host/owner/name`, so no credential embedded in a remote ever leaves the machine.
79
+ """
80
+ text = (value or "").strip()
81
+ if not text:
82
+ return None
83
+ if _UUID.match(text):
84
+ return text.lower()
85
+ scp = _SCP_REMOTE.match(text)
86
+ if scp and "://" not in text:
87
+ host, path = scp.group(1), scp.group(2)
88
+ else:
89
+ try:
90
+ parsed = urllib.parse.urlsplit(text)
91
+ except ValueError:
92
+ return None
93
+ if parsed.scheme not in ("https", "http", "ssh", "git", "git+ssh") or not parsed.hostname:
94
+ return None
95
+ host, path = parsed.hostname, parsed.path
96
+ path = re.sub(r"\.git$", "", path.strip("/").rstrip("/"))
97
+ # A dotted host only: `C:/repo` or a single-label name is a local path or a LAN host, never a match.
98
+ if not path or not re.match(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$", host or ""):
99
+ return None
100
+ url = f"https://{host.lower()}/{path}"
101
+ return url if len(url) <= MAX_REPOSITORY and " " not in url else None
102
+
103
+
104
+ def _git(root: Path, *args: str) -> str | None:
105
+ """One `git` answer for the reviewed folder, or None: no git, no repository, or no answer in time."""
106
+ try:
107
+ done = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True,
108
+ timeout=GIT_TIMEOUT_SECONDS, check=False)
109
+ except (OSError, subprocess.SubprocessError, ValueError):
110
+ return None
111
+ out = done.stdout.strip() if done.returncode == 0 else ""
112
+ return out or None
113
+
114
+
115
+ def detect_commit(root: Path) -> str | None:
116
+ head = _git(root, "rev-parse", "HEAD")
117
+ return head.lower() if head and _SHA.match(head) else None
118
+
119
+
120
+ def detect_repository(root: Path) -> str | None:
121
+ return repository_url(_git(root, "remote", "get-url", "origin"))
122
+
123
+
124
+ def target(
125
+ project: str | None,
126
+ url: str | None,
127
+ token_env: str | None,
128
+ commit: str | None,
129
+ environ: dict[str, str] | None = None,
130
+ repository: str | None = None,
131
+ root: Path | None = None,
132
+ ) -> Target:
133
+ """Check every publish argument before the review runs, so a typo never costs a full review.
134
+
135
+ Without `commit` the commit is `$GITHUB_SHA`, else `git rev-parse HEAD` in `root`; without
136
+ `repository` it is the `origin` remote's URL. A folder without git publishes without them.
137
+ """
138
+ env = os.environ if environ is None else environ
139
+ if not project or not _KEY.match(project):
140
+ raise PublishError("--publish needs --project KEY: the development project's key (2 to 10 letters or digits)")
141
+ if not url:
142
+ raise PublishError("--publish needs --url, the workspace origin (e.g. https://enginehall.com)")
143
+ name = token_env or DEFAULT_TOKEN_ENV
144
+ if not _ENV_NAME.match(name):
145
+ raise PublishError(f"--token-env must name an environment variable, not {name!r}")
146
+ token = (env.get(name) or "").strip()
147
+ if not token:
148
+ raise PublishError(f"--publish needs a project token in the environment variable {name}")
149
+ if commit is not None and not _SHA.match(commit):
150
+ raise PublishError("--commit must be a commit hash (7 to 64 hex digits)")
151
+ if commit is None and _SHA.match(env.get("GITHUB_SHA", "")):
152
+ commit = env["GITHUB_SHA"]
153
+ if repository is not None:
154
+ named = repository_url(repository)
155
+ if named is None:
156
+ raise PublishError("--repository must be a repository id or its URL (https, ssh or git@host:path)")
157
+ repository = named
158
+ if root is not None:
159
+ if commit is None:
160
+ commit = detect_commit(root)
161
+ if repository is None:
162
+ repository = detect_repository(root)
163
+ ref = (env.get("GITHUB_REF_NAME") or "").strip()[:MAX_REF] or None
164
+ source = "ci" if _truthy(env.get("CI")) or _truthy(env.get("GITHUB_ACTIONS")) else "cli"
165
+ return Target(project.upper(), _origin(url), token, commit.lower() if commit else None, ref, source, repository)
166
+
167
+
168
+ def _relative(value: str | None, root: Path) -> str | None:
169
+ if not value:
170
+ return None
171
+ path = Path(value)
172
+ try:
173
+ return path.resolve().relative_to(root).as_posix() or "."
174
+ except ValueError:
175
+ return PurePath(os.path.relpath(path.resolve(), root)).as_posix()
176
+
177
+
178
+ def _scrubber(root: Path):
179
+ """Replace the local root, then the home folder, in a string."""
180
+ pairs = []
181
+ for base, label in ((root, "."), (Path.home(), "~")):
182
+ for form in {str(base), base.as_posix()}:
183
+ if len(form) > 1:
184
+ pairs.append((form, label))
185
+ pairs.sort(key=lambda pair: -len(pair[0]))
186
+
187
+ def scrub(text: str) -> str:
188
+ for old, new in pairs:
189
+ text = text.replace(old, new)
190
+ return text
191
+
192
+ return scrub
193
+
194
+
195
+ def _scrub(value, scrub):
196
+ if isinstance(value, str):
197
+ return scrub(value)
198
+ if isinstance(value, list):
199
+ return [_scrub(item, scrub) for item in value]
200
+ if isinstance(value, dict):
201
+ return {key: _scrub(item, scrub) for key, item in value.items()}
202
+ return value
203
+
204
+
205
+ def report(result) -> dict:
206
+ """The JSON report of a review, with no absolute local path in it."""
207
+ payload = json.loads(result.render("json"))
208
+ root = Path(result.discovery.root).resolve()
209
+ project = payload.get("project") or {}
210
+ project["root"] = "."
211
+ for key in ("schematic_dir", "kicad_project", "ioc"):
212
+ project[key] = _relative(project.get(key), root)
213
+ project["firmware_roots"] = [_relative(p, root) for p in project.get("firmware_roots") or []]
214
+ payload["project"] = project
215
+ return _scrub(payload, _scrubber(root))
216
+
217
+
218
+ def body(result, destination: Target) -> bytes:
219
+ data: dict = {"report": report(result), "source": destination.source}
220
+ if destination.commit:
221
+ data["commit_sha"] = destination.commit
222
+ if destination.ref:
223
+ data["ref"] = destination.ref
224
+ if destination.repository:
225
+ data["repository"] = destination.repository
226
+ encoded = json.dumps(data, separators=(",", ":")).encode("utf-8")
227
+ if len(encoded) > MAX_BODY_BYTES:
228
+ raise PublishError(f"the report is larger than {MAX_BODY_BYTES // (1024 * 1024)} MiB and was not published")
229
+ return encoded
230
+
231
+
232
+ def _reason(error: urllib.error.HTTPError) -> str:
233
+ try:
234
+ detail = json.loads(error.read().decode("utf-8", "replace") or "{}")
235
+ except (ValueError, OSError):
236
+ detail = {}
237
+ message = detail.get("statusMessage") or detail.get("message") if isinstance(detail, dict) else None
238
+ return f"HTTP {error.code}" + (f" ({message})" if isinstance(message, str) and message else "")
239
+
240
+
241
+ def publish(result, destination: Target) -> dict:
242
+ """POST the report; returns the server's answer (`id`, `url`). Raises PublishError."""
243
+ request = urllib.request.Request(
244
+ destination.endpoint,
245
+ data=body(result, destination),
246
+ method="POST",
247
+ headers={
248
+ "Authorization": f"Bearer {destination.token}",
249
+ "Content-Type": "application/json",
250
+ "Accept": "application/json",
251
+ "User-Agent": f"pcba/{__version__}",
252
+ },
253
+ )
254
+ try:
255
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
256
+ raw = response.read()
257
+ except urllib.error.HTTPError as exc:
258
+ raise PublishError(f"could not publish the report: {_reason(exc)}") from None
259
+ except urllib.error.URLError as exc:
260
+ raise PublishError(f"could not publish the report: {exc.reason}") from None
261
+ except (TimeoutError, OSError) as exc:
262
+ raise PublishError(f"could not publish the report: {exc}") from None
263
+ try:
264
+ answer = json.loads(raw.decode("utf-8"))
265
+ except ValueError:
266
+ raise PublishError("could not publish the report: the server's answer is not JSON") from None
267
+ if not isinstance(answer, dict) or not isinstance(answer.get("id"), str):
268
+ raise PublishError("could not publish the report: the server's answer has no check run id")
269
+ return answer
@@ -0,0 +1,7 @@
1
+ """Rendering findings: text for humans, JSON for agents, SARIF for CI."""
2
+
3
+ from pcba.report.json_report import to_json
4
+ from pcba.report.sarif import to_sarif
5
+ from pcba.report.text import to_markdown, to_text
6
+
7
+ __all__ = ["to_json", "to_markdown", "to_sarif", "to_text"]
@@ -0,0 +1,39 @@
1
+ """Machine-readable output.
2
+
3
+ Every consumer that is not a human reads this: the MCP server, CI, and any agent. It
4
+ validates against `schemas/finding.schema.json`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from pcba import __version__
12
+ from pcba.model.finding import Finding, Severity
13
+
14
+
15
+ def to_json(
16
+ findings: list[Finding],
17
+ coverage: dict[str, list[str]] | None = None,
18
+ project: dict | None = None,
19
+ indent: int | None = 2,
20
+ *,
21
+ baseline: dict | None = None,
22
+ changes: dict | None = None,
23
+ ) -> str:
24
+ counts = {str(s): sum(1 for f in findings if f.severity is s) for s in Severity}
25
+ payload = {
26
+ "schema_version": 1,
27
+ "tool": {"name": "pcba", "version": __version__},
28
+ "project": project or {},
29
+ "summary": {"total": len(findings), **counts},
30
+ # Callers gate on this. It is not the same as "no findings" and must not be
31
+ # inferred from an empty list.
32
+ "coverage": coverage or {},
33
+ "findings": [f.to_dict() for f in findings],
34
+ }
35
+ if baseline is not None:
36
+ payload["baseline"] = baseline
37
+ if changes is not None:
38
+ payload["changes"] = changes
39
+ return json.dumps(payload, indent=indent, sort_keys=False)
pcba/report/sarif.py ADDED
@@ -0,0 +1,128 @@
1
+ """SARIF 2.1.0 output.
2
+
3
+ Cheap to emit and it unlocks the VS Code Problems panel, GitHub code scanning, and most CI
4
+ annotators for free — which is why it ships in v1 rather than waiting for a bespoke UI.
5
+
6
+ The awkward part is that SARIF assumes findings live at a line in a text file, and ours
7
+ often live at a pin on a schematic sheet inside a binary container. Rather than fake a line
8
+ number, artifacts get `region` only when we genuinely have one, and the schematic location
9
+ travels in the message and in `properties`.
10
+
11
+ Artifact URIs are the project-relative paths every finding already carries, declared
12
+ relative to `%SRCROOT%` so a viewer opens the right file no matter where the report was
13
+ produced or which directory the consumer runs in.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path, PurePosixPath, PureWindowsPath
21
+ from urllib.parse import quote
22
+
23
+ from pcba import __version__
24
+ from pcba.model.finding import Finding, Severity
25
+
26
+ _LEVEL = {Severity.ERROR: "error", Severity.WARNING: "warning", Severity.INFO: "note"}
27
+ _ROOT_ID = "%SRCROOT%"
28
+
29
+
30
+ def _artifact_location(artifact: str, windows: bool = os.name == "nt") -> dict:
31
+ """A URI reference for a finding's artifact: percent-encoded, so a space, `#` or `%` in a
32
+ file name cannot cut the path short; relative to `%SRCROOT%` unless it lies outside the
33
+ project, where the absolute path becomes a file URI. That path is read with the rules of
34
+ the machine that wrote it: `//server/share` is a UNC share on Windows and a plain path
35
+ elsewhere."""
36
+ flavours = (PureWindowsPath, PurePosixPath) if windows else (PurePosixPath, PureWindowsPath)
37
+ for flavour in flavours:
38
+ path = flavour(artifact)
39
+ if path.is_absolute():
40
+ return {"uri": path.as_uri()}
41
+ return {"uri": quote(artifact, safe="/"), "uriBaseId": _ROOT_ID}
42
+
43
+
44
+ def to_sarif(
45
+ findings: list[Finding],
46
+ root: Path | None = None,
47
+ indent: int | None = 2,
48
+ *,
49
+ coverage: dict | None = None,
50
+ discovery: dict | None = None,
51
+ baseline: dict | None = None,
52
+ changes: dict | None = None,
53
+ ) -> str:
54
+ rules: dict[str, dict] = {}
55
+ results: list[dict] = []
56
+
57
+ for f in findings:
58
+ rules.setdefault(
59
+ f.rule_id,
60
+ {
61
+ "id": f.rule_id,
62
+ "name": f.rule_id,
63
+ "shortDescription": {"text": f.title},
64
+ "fullDescription": {"text": f.explanation},
65
+ "help": {"text": f"{f.consequence}\n\n{f.recommendation}"},
66
+ "defaultConfiguration": {"level": _LEVEL[f.severity]},
67
+ "properties": {"detector": str(f.detector), "domain": str(f.domain)},
68
+ },
69
+ )
70
+
71
+ location: dict = {
72
+ "physicalLocation": {"artifactLocation": _artifact_location(f.location.artifact)}
73
+ }
74
+ if f.location.line:
75
+ location["physicalLocation"]["region"] = {"startLine": f.location.line}
76
+
77
+ evidence = " ".join(e.value for e in f.evidence)
78
+ results.append(
79
+ {
80
+ "ruleId": f.rule_id,
81
+ "level": _LEVEL[f.severity],
82
+ "message": {"text": f"{f.title}. {f.explanation} {evidence}".strip()},
83
+ "locations": [location],
84
+ "partialFingerprints": {"pcbaFindingId": f.id},
85
+ "properties": {
86
+ "detector": str(f.detector),
87
+ "confidence": f.confidence,
88
+ "pin": f.location.pin,
89
+ "sheet": f.location.sheet,
90
+ "component": f.location.component,
91
+ "recommendation": f.recommendation,
92
+ },
93
+ }
94
+ )
95
+
96
+ run: dict = {
97
+ "tool": {
98
+ "driver": {
99
+ "name": "EngineHall",
100
+ "version": __version__,
101
+ "informationUri": "https://enginehall.com",
102
+ "rules": list(rules.values()),
103
+ }
104
+ },
105
+ "results": results,
106
+ }
107
+ if root:
108
+ run["originalUriBaseIds"] = {_ROOT_ID: {"uri": Path(root).resolve().as_uri() + "/"}}
109
+ properties = {}
110
+ if coverage is not None:
111
+ properties["coverage"] = coverage
112
+ if discovery is not None:
113
+ properties["discovery"] = discovery
114
+ if baseline is not None:
115
+ properties["baseline"] = baseline
116
+ if changes is not None:
117
+ properties["changes"] = changes
118
+ if properties:
119
+ run["properties"] = properties
120
+
121
+ return json.dumps(
122
+ {
123
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
124
+ "version": "2.1.0",
125
+ "runs": [run],
126
+ },
127
+ indent=indent,
128
+ )