enginehall 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
@@ -0,0 +1,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
+ )