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 +3 -0
- misch/cli.py +352 -0
- misch/config.py +127 -0
- misch/db/__init__.py +178 -0
- misch/engine/__init__.py +3 -0
- misch/engine/cppcheck.py +149 -0
- misch/report/__init__.py +1 -0
- misch/report/baseline.py +82 -0
- misch/report/dev_render.py +108 -0
- misch/report/deviations.py +235 -0
- misch/report/headlines.py +76 -0
- misch/report/model.py +99 -0
- misch/report/renderers.py +229 -0
- misch/scaffold.py +99 -0
- misch-0.1.0.dist-info/METADATA +138 -0
- misch-0.1.0.dist-info/RECORD +19 -0
- misch-0.1.0.dist-info/WHEEL +4 -0
- misch-0.1.0.dist-info/entry_points.txt +2 -0
- misch-0.1.0.dist-info/licenses/LICENSE +21 -0
misch/__init__.py
ADDED
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
|
misch/engine/__init__.py
ADDED