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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Drive STM32CubeMX's script mode non-interactively.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
CubeMX's `-q <script>` looks like a CLI and is not one. Two things bite, and both present as
|
|
6
|
+
"the tool is just slow":
|
|
7
|
+
|
|
8
|
+
1. **It always builds a GUI.** `MicroXplorer.run()` constructs a JFrame before it reads the
|
|
9
|
+
script, so `-Djava.awt.headless=true` throws HeadlessException and a plain shell launch
|
|
10
|
+
(no WindowServer) blocks forever at 0% CPU with no error at all. Launching through
|
|
11
|
+
LaunchServices (`open -a … --args -q script`) puts it in the logged-in GUI session and the
|
|
12
|
+
script runs.
|
|
13
|
+
|
|
14
|
+
2. **It stops on modal dialogs.** Even in script mode CubeMX raises dialogs ("Memory
|
|
15
|
+
Protection Unit for Cortex-M7", migration prompts, code-gen warnings) and waits for a
|
|
16
|
+
human. That is what a "hang" almost always is. This answers them.
|
|
17
|
+
|
|
18
|
+
Buttons are matched on the accessibility **description**, not the title: Swing exposes
|
|
19
|
+
`AXTitle` as `missing value` for every button, but sets a usable `AXDescription` ("Yes",
|
|
20
|
+
"No", "OK", …).
|
|
21
|
+
|
|
22
|
+
The ceiling, stated plainly
|
|
23
|
+
---------------------------
|
|
24
|
+
macOS only, and it needs a logged-in GUI session plus Accessibility permission. It cannot run
|
|
25
|
+
over ssh, in CI, or on Windows/Linux. That is a property of CubeMX, not of this code, and no
|
|
26
|
+
amount of engineering here removes it — so this is a workstation convenience, never a build
|
|
27
|
+
step. Anything that must run in CI has to read CubeMX's *data* instead of driving its *UI*,
|
|
28
|
+
which is what the rest of the engine does.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from fnmatch import fnmatch
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
APP_CANDIDATES = (
|
|
42
|
+
"/Applications/STMicroelectronics/STM32CubeMX.app",
|
|
43
|
+
"/Applications/STM32CubeMX.app",
|
|
44
|
+
str(Path.home() / "Applications/STMicroelectronics/STM32CubeMX.app"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Dialogs CubeMX raises in script mode, and the answer that is safe by default.
|
|
48
|
+
#
|
|
49
|
+
# The MPU prompt defaults to "No" because accepting it generates a CubeMX-owned MPU_Config()
|
|
50
|
+
# that competes with, and renumbers the regions of, any MPU setup the project already has.
|
|
51
|
+
# Declining leaves the project's own configuration intact, which is the recoverable choice.
|
|
52
|
+
DEFAULT_ANSWERS: tuple[tuple[str, str], ...] = (("Memory Protection Unit*", "No"),)
|
|
53
|
+
|
|
54
|
+
POLL_SECONDS = 2
|
|
55
|
+
|
|
56
|
+
FileSignature = tuple[int, int, int, int]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# How macOS words a denied Accessibility request; the error number alone is ambiguous.
|
|
60
|
+
ACCESS_DENIED = ("not allowed assistive access", "-25211")
|
|
61
|
+
ACCESS_DENIED_MESSAGE = (
|
|
62
|
+
"no Accessibility permission — osascript cannot see CubeMX's windows.\n"
|
|
63
|
+
"Grant it in System Settings > Privacy & Security > Accessibility to the "
|
|
64
|
+
"app running this (Terminal, iTerm, your IDE), then retry."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DriveError(RuntimeError):
|
|
69
|
+
"""A controlled failure while driving CubeMX."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class Result:
|
|
74
|
+
ok: bool
|
|
75
|
+
detail: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _file_signature(path: Path | None) -> FileSignature | None:
|
|
79
|
+
"""Metadata that changes when CubeMX creates or rewrites an expected file."""
|
|
80
|
+
if path is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
stat = path.stat()
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return None
|
|
86
|
+
return (stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_app(explicit: str | None = None) -> Path:
|
|
90
|
+
if explicit:
|
|
91
|
+
path = Path(explicit)
|
|
92
|
+
if not path.exists():
|
|
93
|
+
raise DriveError(f"STM32CubeMX not found at {path}")
|
|
94
|
+
return path
|
|
95
|
+
for candidate in APP_CANDIDATES:
|
|
96
|
+
if Path(candidate).is_dir():
|
|
97
|
+
return Path(candidate)
|
|
98
|
+
raise DriveError(
|
|
99
|
+
"STM32CubeMX.app not found. Pass --app, or install CubeMX.\nTried:\n "
|
|
100
|
+
+ "\n ".join(APP_CANDIDATES)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def preflight() -> None:
|
|
105
|
+
"""Fail early and specifically, rather than after a 300 s timeout."""
|
|
106
|
+
if sys.platform != "darwin":
|
|
107
|
+
raise DriveError(
|
|
108
|
+
"`enginehall cubemx drive` is macOS-only: it drives CubeMX's GUI through "
|
|
109
|
+
"LaunchServices and the Accessibility API. This is a CubeMX limitation — its "
|
|
110
|
+
"script mode is not headless. Read the .ioc with `enginehall cubemx` instead."
|
|
111
|
+
)
|
|
112
|
+
if not shutil.which("osascript"):
|
|
113
|
+
raise DriveError("osascript not found — cannot answer CubeMX's modal dialogs.")
|
|
114
|
+
# Asked directly rather than inferred from an error later: the denial's wording is
|
|
115
|
+
# localized, while "UI elements enabled" answers true or false in every language.
|
|
116
|
+
if _osascript('tell application "System Events" to get UI elements enabled') == "false":
|
|
117
|
+
raise DriveError(ACCESS_DENIED_MESSAGE)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _osascript(script: str) -> str:
|
|
121
|
+
"""Run AppleScript. Empty string for transient errors; raise on the fatal one.
|
|
122
|
+
|
|
123
|
+
A missing Accessibility permission is fatal and worth a clear message: without it we
|
|
124
|
+
cannot even see the dialogs, so every run would silently time out. preflight() asks for
|
|
125
|
+
it directly; here an English "not allowed assistive access" or -25211 is caught as well.
|
|
126
|
+
Everything else means "nothing to see": -1728 (cannot get the process) when CubeMX exits
|
|
127
|
+
between the process check and the query, or before its window exists; -1719 (invalid
|
|
128
|
+
index) while it is still loading its MCU database and has no window yet.
|
|
129
|
+
"""
|
|
130
|
+
proc = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
|
131
|
+
if proc.returncode != 0:
|
|
132
|
+
if any(marker in proc.stderr for marker in ACCESS_DENIED):
|
|
133
|
+
raise DriveError(ACCESS_DENIED_MESSAGE)
|
|
134
|
+
return ""
|
|
135
|
+
return proc.stdout.strip()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _running() -> bool:
|
|
139
|
+
return subprocess.run(["pgrep", "-f", "STM32CubeMX"], capture_output=True).returncode == 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _front_window() -> str:
|
|
143
|
+
return _osascript(
|
|
144
|
+
'tell application "System Events" to tell process "STM32CubeMX" '
|
|
145
|
+
"to get name of front window"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _buttons() -> list[str]:
|
|
150
|
+
raw = _osascript(
|
|
151
|
+
'tell application "System Events" to tell process "STM32CubeMX" to tell front window '
|
|
152
|
+
"to get description of every button"
|
|
153
|
+
)
|
|
154
|
+
return [b.strip() for b in raw.split(",") if b.strip() and b.strip() != "missing value"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _click(description: str) -> bool:
|
|
158
|
+
out = _osascript(
|
|
159
|
+
'tell application "System Events" to tell process "STM32CubeMX" to tell front window '
|
|
160
|
+
f'to click (first button whose description is "{description}")'
|
|
161
|
+
)
|
|
162
|
+
return "error" not in out.lower()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def drive(
|
|
166
|
+
script: Path,
|
|
167
|
+
expect: Path | None = None,
|
|
168
|
+
answers: tuple[tuple[str, str], ...] = (),
|
|
169
|
+
timeout: int = 300,
|
|
170
|
+
app: str | None = None,
|
|
171
|
+
log=sys.stderr,
|
|
172
|
+
) -> Result:
|
|
173
|
+
"""Run a CubeMX `.mx` script, answering dialogs, gated on `expect` appearing."""
|
|
174
|
+
preflight()
|
|
175
|
+
bundle = find_app(app)
|
|
176
|
+
script = Path(script).resolve()
|
|
177
|
+
if not script.is_file():
|
|
178
|
+
raise DriveError(f"script not found: {script}")
|
|
179
|
+
|
|
180
|
+
all_answers = tuple(DEFAULT_ANSWERS) + tuple(answers)
|
|
181
|
+
target = Path(expect).resolve() if expect else None
|
|
182
|
+
target_before = _file_signature(target)
|
|
183
|
+
|
|
184
|
+
if _running():
|
|
185
|
+
raise DriveError("CubeMX is already running; close it after saving your work")
|
|
186
|
+
|
|
187
|
+
subprocess.run(["open", "-a", str(bundle), "--args", "-q", str(script)], check=True)
|
|
188
|
+
print(f"launched CubeMX with {script.name}", file=log)
|
|
189
|
+
|
|
190
|
+
deadline = time.time() + timeout
|
|
191
|
+
answered: set[str] = set()
|
|
192
|
+
while time.time() < deadline:
|
|
193
|
+
target_after = _file_signature(target)
|
|
194
|
+
if target_after is not None and target_after != target_before:
|
|
195
|
+
time.sleep(POLL_SECONDS) # the file appears before CubeMX finishes flushing it
|
|
196
|
+
return Result(True, f"{target} produced or updated")
|
|
197
|
+
if not _running():
|
|
198
|
+
if target:
|
|
199
|
+
return Result(False, f"CubeMX exited without producing or updating {target}")
|
|
200
|
+
return Result(True, "CubeMX exited")
|
|
201
|
+
|
|
202
|
+
window = _front_window()
|
|
203
|
+
# The main window is titled "STM32CubeMX*"; anything else is a modal in the way.
|
|
204
|
+
if window and not window.startswith("STM32CubeMX") and window not in answered:
|
|
205
|
+
buttons = _buttons()
|
|
206
|
+
choice = next((b for pattern, b in all_answers if fnmatch(window, pattern)), None)
|
|
207
|
+
if choice and choice in buttons:
|
|
208
|
+
print(f"dialog {window!r} -> clicking {choice!r}", file=log)
|
|
209
|
+
_click(choice)
|
|
210
|
+
answered.add(window)
|
|
211
|
+
elif buttons:
|
|
212
|
+
# Guessing here could silently accept a destructive migration. Stop and say
|
|
213
|
+
# exactly what to pass.
|
|
214
|
+
return Result(
|
|
215
|
+
False,
|
|
216
|
+
f"unhandled dialog {window!r}; buttons={buttons}\n"
|
|
217
|
+
f' pass --answer "{window}=<button>" to answer it',
|
|
218
|
+
)
|
|
219
|
+
time.sleep(POLL_SECONDS)
|
|
220
|
+
|
|
221
|
+
return Result(False, f"timed out after {timeout}s (front window: {_front_window()!r})")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_answer(raw: str) -> tuple[str, str]:
|
|
225
|
+
if "=" not in raw:
|
|
226
|
+
raise DriveError(f"--answer needs GLOB=BUTTON, got {raw!r}")
|
|
227
|
+
pattern, button = raw.split("=", 1)
|
|
228
|
+
return pattern, button
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def make_script(load: str | None = None, config: Path | None = None, generate: bool = False) -> str:
|
|
232
|
+
"""Build a `.mx` script.
|
|
233
|
+
|
|
234
|
+
`load` takes the **RefName**, not the orderable part number: it resolves to
|
|
235
|
+
`db/mcu/<arg>.xml`, so `STM32H745IITx` works and `STM32H745IIT6` raises "Can't create …".
|
|
236
|
+
Only the x-wildcard file exists.
|
|
237
|
+
"""
|
|
238
|
+
lines = []
|
|
239
|
+
if load:
|
|
240
|
+
lines.append(f"load {load}")
|
|
241
|
+
if config:
|
|
242
|
+
lines.append(f'config load "{Path(config).resolve()}"')
|
|
243
|
+
if generate:
|
|
244
|
+
lines.append("project generate")
|
|
245
|
+
elif config:
|
|
246
|
+
lines.append("config save")
|
|
247
|
+
lines.append("exit")
|
|
248
|
+
return "\n".join(lines) + "\n"
|
pcba/baseline.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Versioned finding suppressions for an established project baseline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pcba.model.finding import Finding
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaselineError(ValueError):
|
|
15
|
+
"""A baseline cannot be read or does not match the published format."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
19
|
+
class Suppression:
|
|
20
|
+
finding_id: str
|
|
21
|
+
reason: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
25
|
+
class Baseline:
|
|
26
|
+
path: Path
|
|
27
|
+
suppressions: tuple[Suppression, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
31
|
+
class AppliedBaseline:
|
|
32
|
+
path: Path
|
|
33
|
+
suppressed: tuple[Finding, ...]
|
|
34
|
+
stale_ids: tuple[str, ...]
|
|
35
|
+
root: Path | None = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"path": _reported_path(self.path, self.root),
|
|
40
|
+
"suppressed": len(self.suppressed),
|
|
41
|
+
"stale": list(self.stale_ids),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _reported_path(path: Path, root: Path | None) -> str:
|
|
46
|
+
"""Project-relative POSIX path, or the filename if the baseline is outside."""
|
|
47
|
+
resolved = path.resolve()
|
|
48
|
+
if root is not None:
|
|
49
|
+
try:
|
|
50
|
+
return resolved.relative_to(Path(root).resolve()).as_posix()
|
|
51
|
+
except ValueError:
|
|
52
|
+
return resolved.name
|
|
53
|
+
return resolved.name if path.is_absolute() else path.as_posix()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load(path: Path | str) -> Baseline:
|
|
57
|
+
source = Path(path)
|
|
58
|
+
try:
|
|
59
|
+
payload = json.loads(source.read_text(encoding="utf-8"))
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
raise BaselineError(f"cannot read baseline {source}: {exc}") from exc
|
|
62
|
+
except UnicodeDecodeError as exc:
|
|
63
|
+
raise BaselineError(f"baseline {source} is not UTF-8: {exc}") from exc
|
|
64
|
+
except json.JSONDecodeError as exc:
|
|
65
|
+
raise BaselineError(f"invalid baseline JSON in {source}: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
if not isinstance(payload, dict):
|
|
68
|
+
raise BaselineError("baseline must be a JSON object")
|
|
69
|
+
unknown = sorted(set(payload) - {"schema_version", "suppressions"})
|
|
70
|
+
if unknown:
|
|
71
|
+
raise BaselineError(f"unknown baseline fields: {', '.join(unknown)}")
|
|
72
|
+
if payload.get("schema_version") != 1:
|
|
73
|
+
raise BaselineError("baseline schema_version must be 1")
|
|
74
|
+
entries = payload.get("suppressions")
|
|
75
|
+
if not isinstance(entries, list):
|
|
76
|
+
raise BaselineError("baseline suppressions must be a list")
|
|
77
|
+
|
|
78
|
+
suppressions: list[Suppression] = []
|
|
79
|
+
seen: set[str] = set()
|
|
80
|
+
for index, entry in enumerate(entries):
|
|
81
|
+
if not isinstance(entry, dict):
|
|
82
|
+
raise BaselineError(f"baseline suppression {index} must be an object")
|
|
83
|
+
unknown = sorted(set(entry) - {"id", "reason"})
|
|
84
|
+
if unknown:
|
|
85
|
+
raise BaselineError(
|
|
86
|
+
f"unknown fields in baseline suppression {index}: {', '.join(unknown)}"
|
|
87
|
+
)
|
|
88
|
+
finding_id = entry.get("id")
|
|
89
|
+
reason = entry.get("reason")
|
|
90
|
+
if not isinstance(finding_id, str) or not finding_id:
|
|
91
|
+
raise BaselineError(f"baseline suppression {index} needs a non-empty id")
|
|
92
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
93
|
+
raise BaselineError(f"baseline suppression {finding_id!r} needs a reason")
|
|
94
|
+
if finding_id in seen:
|
|
95
|
+
raise BaselineError(f"duplicate baseline suppression id: {finding_id}")
|
|
96
|
+
seen.add(finding_id)
|
|
97
|
+
suppressions.append(Suppression(finding_id=finding_id, reason=reason.strip()))
|
|
98
|
+
return Baseline(path=source.resolve(), suppressions=tuple(suppressions))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def apply(
|
|
102
|
+
findings: list[Finding],
|
|
103
|
+
saved: Baseline,
|
|
104
|
+
root: Path | str | None = None,
|
|
105
|
+
) -> tuple[list[Finding], AppliedBaseline]:
|
|
106
|
+
by_id = {finding.id: finding for finding in findings}
|
|
107
|
+
suppressed_ids = {entry.finding_id for entry in saved.suppressions}
|
|
108
|
+
active = [finding for finding in findings if finding.id not in suppressed_ids]
|
|
109
|
+
suppressed = tuple(finding for finding in findings if finding.id in suppressed_ids)
|
|
110
|
+
stale = tuple(sorted(suppressed_ids - by_id.keys()))
|
|
111
|
+
return active, AppliedBaseline(
|
|
112
|
+
path=saved.path,
|
|
113
|
+
suppressed=suppressed,
|
|
114
|
+
stale_ids=stale,
|
|
115
|
+
root=Path(root) if root is not None else None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def write(
|
|
120
|
+
path: Path | str,
|
|
121
|
+
findings: list[Finding],
|
|
122
|
+
*,
|
|
123
|
+
reason: str = "Present when the project baseline was created.",
|
|
124
|
+
) -> Path:
|
|
125
|
+
"""Atomically write a deterministic baseline for the supplied findings."""
|
|
126
|
+
if not reason.strip():
|
|
127
|
+
raise BaselineError("baseline reason must not be empty")
|
|
128
|
+
target = Path(path).resolve()
|
|
129
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
payload = {
|
|
131
|
+
"schema_version": 1,
|
|
132
|
+
"suppressions": [
|
|
133
|
+
{"id": finding.id, "reason": reason.strip()}
|
|
134
|
+
for finding in sorted(findings, key=lambda item: item.id)
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
text = json.dumps(payload, indent=2) + "\n"
|
|
138
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
139
|
+
prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
|
|
140
|
+
)
|
|
141
|
+
temporary = Path(temporary_name)
|
|
142
|
+
try:
|
|
143
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
144
|
+
stream.write(text)
|
|
145
|
+
stream.flush()
|
|
146
|
+
os.fsync(stream.fileno())
|
|
147
|
+
temporary.replace(target)
|
|
148
|
+
except BaseException:
|
|
149
|
+
temporary.unlink(missing_ok=True)
|
|
150
|
+
raise
|
|
151
|
+
return target
|
pcba/changes.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Git-backed filtering for findings affected by a change set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pcba.model.finding import Finding
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChangeSetError(RuntimeError):
|
|
14
|
+
"""Git could not resolve the requested change set."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
18
|
+
class AppliedChangeFilter:
|
|
19
|
+
base: str
|
|
20
|
+
files: tuple[str, ...]
|
|
21
|
+
omitted: int
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
return {"base": self.base, "files": list(self.files), "omitted": self.omitted}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _git(root: Path, *args: str) -> list[str]:
|
|
28
|
+
try:
|
|
29
|
+
completed = subprocess.run(
|
|
30
|
+
["git", *args],
|
|
31
|
+
cwd=root,
|
|
32
|
+
check=True,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
)
|
|
35
|
+
except FileNotFoundError as exc:
|
|
36
|
+
raise ChangeSetError("git is required for --changed-only") from exc
|
|
37
|
+
except subprocess.CalledProcessError as exc:
|
|
38
|
+
output = exc.stderr.strip() or exc.stdout.strip()
|
|
39
|
+
raise ChangeSetError(output.decode(errors="replace") or "git command failed") from exc
|
|
40
|
+
# Names are bytes as git stored them, decoded the way Python decodes file names: the console
|
|
41
|
+
# locale (cp1252 on many Windows machines) would turn "mäin.c" into "mäin.c".
|
|
42
|
+
return [os.fsdecode(name) for name in completed.stdout.split(b"\0") if name]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def changed_files(root: Path | str, base: str) -> tuple[str, ...]:
|
|
46
|
+
"""Return tracked and untracked paths relative to the project root."""
|
|
47
|
+
if not base or base.startswith("-"):
|
|
48
|
+
raise ChangeSetError("git revision must not look like a command option")
|
|
49
|
+
project = Path(root).resolve()
|
|
50
|
+
# -z: without it git quotes names with non-ASCII bytes, spaces or quotes ("m\303\244in.c"),
|
|
51
|
+
# and a quoted name never matches a finding's artifact.
|
|
52
|
+
tracked = _git(project, "diff", "--name-only", "-z", "--relative", base, "--")
|
|
53
|
+
untracked = _git(project, "ls-files", "-z", "--others", "--exclude-standard")
|
|
54
|
+
return tuple(sorted({Path(name).as_posix() for name in tracked + untracked}))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _finding_artifacts(finding: Finding) -> set[str]:
|
|
58
|
+
artifacts = {finding.location.artifact}
|
|
59
|
+
for evidence in finding.evidence:
|
|
60
|
+
if not evidence.source:
|
|
61
|
+
continue
|
|
62
|
+
source, separator, line = evidence.source.rpartition(":")
|
|
63
|
+
artifacts.add(source if separator and line.isdigit() else evidence.source)
|
|
64
|
+
return artifacts
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply(
|
|
68
|
+
findings: list[Finding], root: Path | str, base: str
|
|
69
|
+
) -> tuple[list[Finding], AppliedChangeFilter]:
|
|
70
|
+
files = changed_files(root, base)
|
|
71
|
+
changed = set(files)
|
|
72
|
+
selected = (
|
|
73
|
+
findings
|
|
74
|
+
if "pcba-studio.toml" in changed
|
|
75
|
+
else [finding for finding in findings if _finding_artifacts(finding) & changed]
|
|
76
|
+
)
|
|
77
|
+
return selected, AppliedChangeFilter(
|
|
78
|
+
base=base,
|
|
79
|
+
files=files,
|
|
80
|
+
omitted=len(findings) - len(selected),
|
|
81
|
+
)
|