misch 0.1.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.
misch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """misch: config-driven MISRA C:2023 analysis for arbitrary C projects."""
2
+
3
+ __version__ = "0.1.0"
misch/cli.py ADDED
@@ -0,0 +1,352 @@
1
+ """`misch` command-line entry point: run, init, baseline, deviations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections import Counter
8
+ from functools import partial
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich_argparse import RichHelpFormatter
13
+
14
+ from . import __version__
15
+ from . import db as dbmod
16
+ from .config import ConfigError, load
17
+ from .engine import cppcheck
18
+ from .engine.cppcheck import EngineError
19
+ from .report.baseline import diff as bl_diff
20
+ from .report.baseline import load_baseline, write_baseline
21
+ from .report.dev_render import render_markdown as render_dev_markdown
22
+ from .report.dev_render import render_terminal as render_dev_terminal
23
+ from .report.deviations import (
24
+ DeviationRecord,
25
+ discover_sources,
26
+ find_stale,
27
+ harvest_inline,
28
+ parse_suppressions_file,
29
+ )
30
+ from .report.headlines import load_headlines
31
+ from .report.model import Report
32
+ from .report.renderers import (
33
+ render_baseline_summary,
34
+ render_json,
35
+ render_sarif,
36
+ render_terminal,
37
+ )
38
+ from .scaffold import ScaffoldParams, build_config
39
+
40
+ _err = Console(stderr=True)
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ parser = argparse.ArgumentParser(
45
+ prog="misch",
46
+ description="Config-driven MISRA C:2023 analysis for arbitrary C projects.",
47
+ formatter_class=RichHelpFormatter,
48
+ )
49
+ parser.add_argument(
50
+ "--version", action="version", version=f"%(prog)s {__version__}"
51
+ )
52
+ sub = parser.add_subparsers(dest="cmd", required=True)
53
+ add_parser = partial(sub.add_parser, formatter_class=RichHelpFormatter)
54
+
55
+ p_run = add_parser("run", help="analyse a project")
56
+ p_run.add_argument("-c", "--config", default="misra.toml", type=Path)
57
+ p_run.add_argument(
58
+ "--format",
59
+ action="append",
60
+ default=[],
61
+ help="override outputs, e.g. --format json (repeatable)",
62
+ )
63
+ p_run.add_argument("--output", type=Path, help="path for a file format")
64
+ p_run.add_argument(
65
+ "--baseline",
66
+ action="store_true",
67
+ help="ratchet mode: report all, but fail only on findings not in the baseline",
68
+ )
69
+ p_run.add_argument(
70
+ "-v",
71
+ "--verbose",
72
+ action="store_true",
73
+ help="also print the per-location listing (default: table + summary)",
74
+ )
75
+ p_run.set_defaults(func=_cmd_run)
76
+
77
+ _add_init_parser(add_parser)
78
+
79
+ p_bl = add_parser("baseline", help="snapshot current findings as the baseline")
80
+ p_bl.add_argument("-c", "--config", default="misra.toml", type=Path)
81
+ p_bl.add_argument("--baseline-file", type=Path, help="override the baseline path")
82
+ p_bl.set_defaults(func=_cmd_baseline)
83
+
84
+ p_dev = add_parser(
85
+ "deviations", help="harvest + validate MISRA deviations (suppressions)"
86
+ )
87
+ p_dev.add_argument("-c", "--config", default="misra.toml", type=Path)
88
+ p_dev.add_argument(
89
+ "--check-stale",
90
+ action="store_true",
91
+ help="run the engine to flag suppressions that match no finding",
92
+ )
93
+ p_dev.add_argument(
94
+ "--format", action="append", default=[], help="e.g. --format md (repeatable)"
95
+ )
96
+ p_dev.add_argument("--output", type=Path, help="path for the Markdown record")
97
+ p_dev.set_defaults(func=_cmd_deviations)
98
+
99
+ args = parser.parse_args(argv)
100
+ return args.func(args)
101
+
102
+
103
+ def _add_init_parser(add_parser) -> None:
104
+ p = add_parser("init", help="generate a misra.toml template")
105
+ p.add_argument(
106
+ "-o",
107
+ "--output",
108
+ type=Path,
109
+ default=Path("misra.toml"),
110
+ help="where to write (default: ./misra.toml)",
111
+ )
112
+ p.add_argument("--force", action="store_true", help="overwrite an existing file")
113
+ p.add_argument(
114
+ "--db",
115
+ choices=["meson", "cmake", "existing"],
116
+ default="meson",
117
+ help="compile-DB source (default: meson)",
118
+ )
119
+ p.add_argument("--db-path", help="compile_commands.json path (for --db existing)")
120
+ p.add_argument(
121
+ "--scope",
122
+ action="append",
123
+ default=[],
124
+ metavar="GLOB",
125
+ help="analysed source root (repeatable; default: src/)",
126
+ )
127
+ p.add_argument(
128
+ "--exclude",
129
+ action="append",
130
+ default=[],
131
+ metavar="GLOB",
132
+ help="excluded path (repeatable; default: tests/, subprojects/)",
133
+ )
134
+ p.add_argument("--platform", default="unix64", help="cppcheck platform preset")
135
+ p.add_argument(
136
+ "--platform-xml", help="custom cppcheck platform XML (overrides --platform)"
137
+ )
138
+ p.add_argument("--rule-texts", default="${MISRA_RULE_TEXTS}", help="headlines path")
139
+ p.add_argument(
140
+ "--define",
141
+ action="append",
142
+ default=[],
143
+ metavar="D",
144
+ help="extra -D define for cppcheck (repeatable)",
145
+ )
146
+ p.set_defaults(func=_cmd_init)
147
+
148
+
149
+ class _AnalysisError(Exception):
150
+ """Carries the exit code for a failed analysis pipeline."""
151
+
152
+ def __init__(self, code: int):
153
+ self.code = code
154
+
155
+
156
+ def _analyse(cfg, *, inline_suppr: bool = True) -> tuple[Report, dbmod.ScopeCoverage]:
157
+ """Shared pipeline: db -> scope-check -> engine -> in-scope Report.
158
+
159
+ Raises _AnalysisError(code) on any gate so callers just propagate the code.
160
+ With inline_suppr=False, cppcheck-suppress comments are ignored (used by the
161
+ deviation staleness check).
162
+ """
163
+ if cfg.rule_texts is None:
164
+ _err.print(
165
+ "[yellow]note:[/] no MISRA rule-texts found (set $MISRA_RULE_TEXTS "
166
+ "or \\[rules].texts). Findings will be tagged category: unknown. "
167
+ "See docs/rule-texts.md."
168
+ )
169
+ rules = load_headlines(cfg.rule_texts) if cfg.rule_texts else {}
170
+
171
+ try:
172
+ db_path = dbmod.resolve_compile_db(cfg)
173
+ except dbmod.DbError as exc:
174
+ _err.print(f"[red]compile-db error:[/] {exc}")
175
+ raise _AnalysisError(2) from None
176
+
177
+ coverage = dbmod.classify_files(cfg, db_path)
178
+ if not coverage.ok():
179
+ _err.print(
180
+ f"[red]scope error:[/] {len(coverage.unattributed)} file(s) match "
181
+ "neither \\[project].scope nor \\[project].exclude. Classify them "
182
+ "explicitly (audit boundary must be enumerated):"
183
+ )
184
+ for f in coverage.unattributed:
185
+ _err.print(f" [red]?[/] {f}")
186
+ raise _AnalysisError(2)
187
+
188
+ try:
189
+ findings = cppcheck.run(cfg, db_path, rules, inline_suppr=inline_suppr)
190
+ except EngineError as exc:
191
+ _err.print(f"[red]engine error:[/] {exc}")
192
+ raise _AnalysisError(2) from None
193
+
194
+ # Post-filter is authoritative: only report in-scope, non-excluded
195
+ # locations. Findings at in-tree locations that match neither scope nor
196
+ # exclude (headers never appear in the compile DB, so classify_files
197
+ # cannot gate them) are a hard error, not a silent drop.
198
+ findings, unattributed = dbmod.partition_findings(cfg, findings)
199
+ if unattributed:
200
+ by_file = Counter(f.file for f in unattributed)
201
+ _err.print(
202
+ f"[red]scope error:[/] {len(unattributed)} finding(s) at "
203
+ "location(s) matching neither \\[project].scope nor "
204
+ "\\[project].exclude. Classify them explicitly "
205
+ "(audit boundary must be enumerated):"
206
+ )
207
+ for file, n in sorted(by_file.items()):
208
+ _err.print(f" [red]?[/] {file} ({n})")
209
+ raise _AnalysisError(2)
210
+ report = Report(
211
+ findings=findings,
212
+ analysed_files=coverage.analysed,
213
+ excluded_files=coverage.excluded,
214
+ )
215
+ return report, coverage
216
+
217
+
218
+ def _cmd_run(args: argparse.Namespace) -> int:
219
+ try:
220
+ cfg = load(args.config)
221
+ except ConfigError as exc:
222
+ _err.print(f"[red]config error:[/] {exc}")
223
+ return 2
224
+
225
+ try:
226
+ report, coverage = _analyse(cfg)
227
+ except _AnalysisError as exc:
228
+ return exc.code
229
+
230
+ _emit(cfg, report, coverage, args)
231
+
232
+ if not args.baseline:
233
+ return 1 if report.misra() else 0
234
+
235
+ # Ratchet mode: report everything but fail only on findings not in baseline.
236
+ baseline = load_baseline(cfg.baseline_path)
237
+ if not baseline:
238
+ _err.print(
239
+ f"[yellow]note:[/] --baseline set but {cfg.baseline_path.name} is "
240
+ "empty/absent. Run [bold]misch baseline[/] to create it."
241
+ )
242
+ d = bl_diff(report.misra(), baseline)
243
+ render_baseline_summary(d, cfg.baseline_path)
244
+ return 1 if d.new else 0
245
+
246
+
247
+ def _cmd_baseline(args: argparse.Namespace) -> int:
248
+ try:
249
+ cfg = load(args.config)
250
+ except ConfigError as exc:
251
+ _err.print(f"[red]config error:[/] {exc}")
252
+ return 2
253
+ try:
254
+ report, _ = _analyse(cfg)
255
+ except _AnalysisError as exc:
256
+ return exc.code
257
+
258
+ path = args.baseline_file or cfg.baseline_path
259
+ n = write_baseline(path, report.misra())
260
+ _err.print(
261
+ f"[green]wrote baseline[/] {path}: {n} finding(s) accepted. "
262
+ "Future [bold]misch run --baseline[/] fails only on new ones."
263
+ )
264
+ return 0
265
+
266
+
267
+ def _cmd_deviations(args: argparse.Namespace) -> int:
268
+ try:
269
+ cfg = load(args.config)
270
+ except ConfigError as exc:
271
+ _err.print(f"[red]config error:[/] {exc}")
272
+ return 2
273
+
274
+ known = set(load_headlines(cfg.rule_texts)) if cfg.rule_texts else set()
275
+
276
+ record = DeviationRecord()
277
+ record.deviations.extend(
278
+ harvest_inline(discover_sources(cfg), cfg.project_root, known)
279
+ )
280
+ if cfg.suppressions_path and cfg.suppressions_path.is_file():
281
+ record.deviations.extend(
282
+ parse_suppressions_file(cfg.suppressions_path, cfg.project_root, known)
283
+ )
284
+
285
+ if args.check_stale:
286
+ try:
287
+ # An unsuppressed run reveals what each suppression actually hides.
288
+ report, _ = _analyse(cfg, inline_suppr=False)
289
+ record.stale = find_stale(record.deviations, report.findings)
290
+ except _AnalysisError:
291
+ _err.print("[yellow]note:[/] staleness check skipped (analysis failed).")
292
+
293
+ render_dev_terminal(record)
294
+ for out in [{"format": f} for f in args.format]:
295
+ if out["format"] == "md":
296
+ path = args.output or (cfg.build_dir / "deviations.md")
297
+ render_dev_markdown(record, path)
298
+ _err.print(f"[dim]wrote deviation record to {path}[/]")
299
+ else:
300
+ fmt = out["format"]
301
+ _err.print(f"[yellow]note:[/] deviations format {fmt!r} unsupported")
302
+
303
+ return 0 if record.ok() else 1
304
+
305
+
306
+ def _emit(cfg, report: Report, coverage, args: argparse.Namespace) -> None:
307
+ outputs = [{"format": f} for f in args.format] if args.format else list(cfg.outputs)
308
+ for out in outputs:
309
+ fmt = out["format"]
310
+ if fmt == "terminal":
311
+ render_terminal(report, coverage, verbose=getattr(args, "verbose", False))
312
+ elif fmt == "json":
313
+ path = args.output or Path(out.get("path", cfg.build_dir / "misra.json"))
314
+ render_json(report, coverage, path)
315
+ _err.print(f"[dim]wrote JSON findings to {path}[/]")
316
+ elif fmt == "sarif":
317
+ path = args.output or Path(out.get("path", cfg.build_dir / "misra.sarif"))
318
+ render_sarif(report, path)
319
+ _err.print(f"[dim]wrote SARIF to {path}[/]")
320
+ else:
321
+ _err.print(
322
+ f"[yellow]note:[/] output format {fmt!r} unsupported "
323
+ "(terminal/json/sarif)"
324
+ )
325
+
326
+
327
+ def _cmd_init(args: argparse.Namespace) -> int:
328
+ out: Path = args.output
329
+ if out.exists() and not args.force:
330
+ _err.print(f"[red]refusing to overwrite {out}[/] (pass --force)")
331
+ return 2
332
+
333
+ params = ScaffoldParams(
334
+ scope=args.scope or ["src/"],
335
+ exclude=args.exclude or ["tests/", "subprojects/"],
336
+ db_source=args.db,
337
+ db_path=args.db_path,
338
+ platform_preset=args.platform,
339
+ platform_xml=args.platform_xml,
340
+ rule_texts=args.rule_texts,
341
+ defines=args.define,
342
+ )
343
+ out.write_text(build_config(params))
344
+ _err.print(
345
+ f"[green]wrote {out}[/]. Review the scope/exclude and db source, "
346
+ "then run [bold]misch run[/]."
347
+ )
348
+ return 0
349
+
350
+
351
+ if __name__ == "__main__":
352
+ sys.exit(main())
misch/config.py ADDED
@@ -0,0 +1,127 @@
1
+ """Load and validate `misra.toml`.
2
+
3
+ Everything that differs between projects is data here, not shell. See
4
+ docs/DESIGN.md for the full schema.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+
15
+ class ConfigError(Exception):
16
+ pass
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class Config:
21
+ project_root: Path
22
+ scope: list[str]
23
+ exclude: list[str]
24
+ db_source: str # meson | cmake | existing
25
+ db_path: str | None
26
+ platform: str # cppcheck preset name or path to a platform XML
27
+ defines: list[str]
28
+ rule_texts: str | None # resolved absolute path, or None (BYO absent)
29
+ outputs: list[dict] # [{"format": "terminal"}, {"format": "json", "path": ...}]
30
+ baseline_path: Path
31
+ suppressions_path: Path | None # cppcheck project suppressions file, if any
32
+
33
+ build_dir: Path = field(init=False)
34
+
35
+ def __post_init__(self) -> None:
36
+ self.build_dir = self.project_root / "build_analysis"
37
+
38
+
39
+ # For build systems without native compile-DB export (plain Make), generate
40
+ # one with an interceptor such as `bear -- make` and use source = "existing".
41
+ _VALID_DB_SOURCES = {"meson", "cmake", "existing"}
42
+
43
+
44
+ def load(config_path: Path) -> Config:
45
+ config_path = config_path.resolve()
46
+ if not config_path.is_file():
47
+ raise ConfigError(f"config not found: {config_path}")
48
+ root = config_path.parent
49
+
50
+ with open(config_path, "rb") as fh:
51
+ data = tomllib.load(fh)
52
+
53
+ project = data.get("project", {})
54
+ db = data.get("db", {})
55
+ platform = data.get("platform", {})
56
+ toolchain = data.get("toolchain", {})
57
+ rules = data.get("rules", {})
58
+ report = data.get("report", {})
59
+ baseline = data.get("baseline", {})
60
+ deviations = data.get("deviations", {})
61
+
62
+ db_source = db.get("source", "existing")
63
+ if db_source not in _VALID_DB_SOURCES:
64
+ raise ConfigError(
65
+ f"db.source must be one of {sorted(_VALID_DB_SOURCES)}, got {db_source!r}"
66
+ )
67
+
68
+ plat = platform.get("xml") or platform.get("preset") or "unix64"
69
+
70
+ outputs = report.get("outputs") or [{"format": "terminal"}]
71
+ outputs = [_norm_output(o) for o in outputs]
72
+
73
+ return Config(
74
+ project_root=root,
75
+ scope=list(project.get("scope", [])),
76
+ exclude=list(project.get("exclude", [])),
77
+ db_source=db_source,
78
+ db_path=db.get("path"),
79
+ platform=plat,
80
+ defines=list(toolchain.get("defines", [])),
81
+ rule_texts=_resolve_rule_texts(rules.get("texts"), root),
82
+ outputs=outputs,
83
+ baseline_path=_resolve_path(baseline.get("path"), root, "misra-baseline.json"),
84
+ suppressions_path=_optional_path(deviations.get("suppressions"), root),
85
+ )
86
+
87
+
88
+ def _optional_path(configured: str | None, root: Path) -> Path | None:
89
+ if not configured:
90
+ return None
91
+ p = Path(configured)
92
+ return p if p.is_absolute() else (root / p)
93
+
94
+
95
+ def _resolve_path(configured: str | None, root: Path, default: str) -> Path:
96
+ p = Path(configured) if configured else Path(default)
97
+ return p if p.is_absolute() else (root / p)
98
+
99
+
100
+ def _norm_output(o: object) -> dict:
101
+ if isinstance(o, str):
102
+ return {"format": o}
103
+ if isinstance(o, dict) and "format" in o:
104
+ return dict(o)
105
+ raise ConfigError(f"invalid report output entry: {o!r}")
106
+
107
+
108
+ def _resolve_rule_texts(configured: str | None, root: Path) -> str | None:
109
+ """Precedence: $MISRA_RULE_TEXTS > misra.toml [rules].texts.
110
+
111
+ Returns an absolute path if a readable file is found, else None (the run
112
+ still works; findings are tagged category: unknown).
113
+ """
114
+ env = os.environ.get("MISRA_RULE_TEXTS")
115
+ candidates = [env] if env else []
116
+ if configured:
117
+ candidates.append(os.path.expandvars(configured))
118
+
119
+ for cand in candidates:
120
+ if not cand:
121
+ continue
122
+ p = Path(cand)
123
+ if not p.is_absolute():
124
+ p = root / p
125
+ if p.is_file():
126
+ return str(p.resolve())
127
+ return None
misch/db/__init__.py ADDED
@@ -0,0 +1,178 @@
1
+ """Compile-database resolution and scope classification.
2
+
3
+ `compile_commands.json` is the universal seam: every project-specific build
4
+ concern collapses into "produce a normalised compile DB". This module obtains
5
+ that DB (from an existing file, or by configuring Meson/CMake) and classifies
6
+ every file it references as analysed / excluded / unattributed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import json
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from ..config import Config
19
+
20
+
21
+ class DbError(Exception):
22
+ pass
23
+
24
+
25
+ def resolve_compile_db(cfg: Config) -> Path:
26
+ """Return a *normalised* compile_commands.json for the configured source.
27
+
28
+ Normalisation rewrites every entry's `file` to an absolute path (resolved
29
+ against its `directory`), so downstream scope classification and the engine
30
+ see uniform paths regardless of whether the build system emitted relative or
31
+ absolute ones.
32
+ """
33
+ if cfg.db_source == "existing":
34
+ rel = cfg.db_path or "build/compile_commands.json"
35
+ raw = (cfg.project_root / rel).resolve()
36
+ if not raw.is_file():
37
+ raise DbError(f"db.source=existing but {raw} does not exist")
38
+ elif cfg.db_source == "meson":
39
+ raw = _meson_db(cfg)
40
+ elif cfg.db_source == "cmake":
41
+ raw = _cmake_db(cfg)
42
+ else:
43
+ raise DbError(f"unsupported db.source: {cfg.db_source!r}")
44
+
45
+ return _normalise_db(raw, cfg.build_dir)
46
+
47
+
48
+ def _normalise_db(raw: Path, out_dir: Path) -> Path:
49
+ entries = json.loads(raw.read_text())
50
+ for e in entries:
51
+ f = Path(e["file"])
52
+ if not f.is_absolute():
53
+ f = Path(e.get("directory", ".")) / f
54
+ e["file"] = str(f.resolve())
55
+ out_dir.mkdir(parents=True, exist_ok=True)
56
+ out = out_dir / "compile_commands.normalised.json"
57
+ out.write_text(json.dumps(entries))
58
+ return out
59
+
60
+
61
+ def _meson_db(cfg: Config) -> Path:
62
+ db = cfg.build_dir / "compile_commands.json"
63
+ if not db.is_file():
64
+ _require("meson")
65
+ subprocess.run(
66
+ ["meson", "setup", str(cfg.build_dir), str(cfg.project_root)],
67
+ check=True,
68
+ )
69
+ if not db.is_file():
70
+ raise DbError(f"meson did not produce {db}")
71
+ return db
72
+
73
+
74
+ def _cmake_db(cfg: Config) -> Path:
75
+ db = cfg.build_dir / "compile_commands.json"
76
+ if not db.is_file():
77
+ _require("cmake")
78
+ subprocess.run(
79
+ [
80
+ "cmake",
81
+ "-S",
82
+ str(cfg.project_root),
83
+ "-B",
84
+ str(cfg.build_dir),
85
+ "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
86
+ ],
87
+ check=True,
88
+ )
89
+ if not db.is_file():
90
+ raise DbError(f"cmake did not produce {db}")
91
+ return db
92
+
93
+
94
+ def _require(tool: str) -> None:
95
+ if shutil.which(tool) is None:
96
+ raise DbError(f"{tool} not found on PATH (needed for db.source)")
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class ScopeCoverage:
101
+ analysed: list[str]
102
+ excluded: list[str]
103
+ unattributed: list[str]
104
+
105
+ def ok(self) -> bool:
106
+ return not self.unattributed
107
+
108
+
109
+ def classify_files(cfg: Config, db_path: Path) -> ScopeCoverage:
110
+ """Bucket every DB file into analysed / excluded / unattributed.
111
+
112
+ Enforces the "exclusion is explicit and enumerated" rule: a file that
113
+ matches neither `scope` nor `exclude` is unattributed, which is a hard
114
+ error so nothing is ever silently ignored.
115
+ """
116
+ entries = json.loads(db_path.read_text())
117
+ files = sorted({_rel(cfg.project_root, e["file"]) for e in entries})
118
+
119
+ analysed, excluded, unattributed = [], [], []
120
+ for f in files:
121
+ if _matches_any(f, cfg.exclude):
122
+ excluded.append(f)
123
+ elif not cfg.scope or _matches_any(f, cfg.scope):
124
+ analysed.append(f)
125
+ else:
126
+ unattributed.append(f)
127
+ return ScopeCoverage(analysed, excluded, unattributed)
128
+
129
+
130
+ def _rel(root: Path, file: str) -> str:
131
+ p = Path(file)
132
+ try:
133
+ return p.resolve().relative_to(root.resolve()).as_posix()
134
+ except ValueError:
135
+ return p.as_posix() # outside the tree (system/toolchain header)
136
+
137
+
138
+ def _matches_any(path: str, globs: list[str]) -> bool:
139
+ for g in globs:
140
+ g = g.strip()
141
+ if not g:
142
+ continue
143
+ base = g.rstrip("/")
144
+ # Directory-style pattern: match anything beneath it.
145
+ if path == base or path.startswith(base + "/"):
146
+ return True
147
+ if fnmatch.fnmatch(path, g):
148
+ return True
149
+ return False
150
+
151
+
152
+ def in_scope(cfg: Config, path: str) -> bool:
153
+ """True if a finding at `path` should be reported (in scope, not excluded)."""
154
+ if _matches_any(path, cfg.exclude):
155
+ return False
156
+ return not cfg.scope or _matches_any(path, cfg.scope)
157
+
158
+
159
+ def partition_findings(cfg: Config, findings: list) -> tuple[list, list]:
160
+ """Split findings into (kept, unattributed-location).
161
+
162
+ The compile DB only lists translation units, so `classify_files` cannot see
163
+ headers: they enter the analysis via inclusion, and cppcheck reports
164
+ findings at their locations. A finding inside the project tree whose path
165
+ matches neither `scope` nor `exclude` is therefore unclassified audit
166
+ territory and must be surfaced, not silently dropped. Findings at
167
+ explicitly excluded paths and outside the tree (system/toolchain headers;
168
+ `_rel` leaves those absolute) are dropped silently by design.
169
+ """
170
+ kept, unattributed = [], []
171
+ for f in findings:
172
+ if not f.file:
173
+ continue
174
+ if in_scope(cfg, f.file):
175
+ kept.append(f)
176
+ elif not Path(f.file).is_absolute() and not _matches_any(f.file, cfg.exclude):
177
+ unattributed.append(f)
178
+ return kept, unattributed
@@ -0,0 +1,3 @@
1
+ """Static-analysis engines. cppcheck is the default; the interface is kept
2
+ narrow so a commercial tool can slot in behind the same Finding model later.
3
+ """