diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 00000000..67aa8d22 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: decaylanguage-validate + name: Validate EvtGen decay files + description: Validate .dec decay files with decaylanguage.DecFileParser. + entry: decaylanguage-validate + language: python + files: '\.(dec|DEC)$' diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3ba0bd..b0626990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Parsing of decay files (aka .dec files): - Various improvements to the code, for more robustness. + - Added `decaylanguage-validate` and a pre-commit hook for validating + EvtGen `.dec` files with selectable diagnostic codes. - Performance improvements in `DecFileParser`, with caching and lazily-built indexing where possible. - A couple of fixes related to the decay file parser. - Typing modernisations. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 1d66ab13..5c35253f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -82,3 +82,22 @@ Tips To run a subset of tests:: nox -s tests-3.9 -- -k test_myfeature + +Decay file pre-commit hook +-------------------------- + +Downstream projects can validate EvtGen ``.dec`` files with the packaged +pre-commit hook:: + + - repo: https://github.com/scikit-hep/decaylanguage + rev: + hooks: + - id: decaylanguage-validate + +The hook reports selectable diagnostic codes. Experiments can ignore exact +codes or whole code families, for example:: + + - id: decaylanguage-validate + args: ["--ignore=DLW004"] + +Use ``decaylanguage-validate --list-diagnostics`` to list the current codes. diff --git a/README.md b/README.md index b7ef7549..08cd110f 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ Just run the following: pip install decaylanguage ``` -The amplitude `modeling` subpackage and the command-line interface need a few -extra dependencies (NumPy, pandas and plumbum). Install them with: +The amplitude `modeling` subpackage and the AmpGen-to-GooFit command-line +interface need a few extra dependencies (NumPy, pandas and plumbum). Install +them with: ```bash pip install "decaylanguage[modeling]" @@ -179,6 +180,73 @@ dfp.parse() This being said, please do submit a pull request to add new models, if you spot missing ones ... +#### Validating decay files + +Decay files can be validated from the command line: + +```bash +decaylanguage-validate my-decay-file.dec +decaylanguage-validate path/to/decfiles-directory +``` + +The validator is also available as a pre-commit hook for downstream projects: + +```yaml +- repo: https://github.com/scikit-hep/decaylanguage + rev: + hooks: + - id: decaylanguage-validate +``` + +Diagnostics use stable codes, which can be disabled per experiment policy. For +example, LHCb-style files that intentionally rely on unmatched `CDecay` source +decays can ignore `DLW004`: + +```yaml +- id: decaylanguage-validate + args: ["--ignore=DLW004"] +``` + +Run `decaylanguage-validate --list-diagnostics` to list the currently available +codes. + +Available diagnostics: + +| Code | Name | Meaning | +| --- | --- | --- | +| `DLP001` | `parse-error` | The file could not be parsed by `DecFileParser`. | +| `DLW001` | `duplicate-decay` | A particle has multiple `Decay` blocks; only the first is retained. | +| `DLW002` | `missing-copydecay-source` | A `CopyDecay` statement references a missing `Decay` source. | +| `DLW003` | `duplicate-cdecay` | A particle is defined with both `Decay` and `CDecay`; `CDecay` is ignored. | +| `DLW004` | `missing-cdecay-source` | A `CDecay` statement has no corresponding `Decay` source. | +| `DLW005` | `self-conjugate-cdecay` | A `CDecay` statement targets a self-conjugate particle. | +| `DLW999` | `parser-warning` | An otherwise unclassified warning was emitted by `DecFileParser`. | + +When the hook finds a problem, pre-commit prints the validator output. A parser +error includes the source line and column pointer: + +```text +Validate EvtGen decay files..............................................Failed +- hook id: decaylanguage-validate +- exit code: 1 + +DecayLanguage: 1 diagnostic(s) in 1 file(s) +tests/data/broken.dec:13:68: DLP001 parse-error: UnexpectedToken: Unexpected token Token('SIGNED_NUMBER', '2') at line 13, column 68. + 13: 0.000044342 Upsilon pi0 pi0 VVPIPI;2 #[Reconstructed PDG2011] + ^ +summary: DLP001=1 +``` + +Parser warnings are shorter: + +```text +tests/data/example.dec: DLW004 missing-cdecay-source: missing Decay source for CDecay: anti-B0sig +summary: DLW004=1 +``` + +By default, the validator prints up to 100 diagnostics and then summarizes the +rest. Use `--max-diagnostics=0` to print every diagnostic. + ### Visualize decay files The class `DecayChainViewer` allows the visualization of parsed decay chains: diff --git a/docs/api/dec.rst b/docs/api/dec.rst index bd5c5b34..6decd477 100644 --- a/docs/api/dec.rst +++ b/docs/api/dec.rst @@ -4,3 +4,9 @@ Decay file parser — :mod:`decaylanguage.dec` .. automodule:: decaylanguage.dec.dec :members: :undoc-members: + +Decay file validation — :mod:`decaylanguage.dec.validate` +--------------------------------------------------------- + +.. automodule:: decaylanguage.dec.validate + :members: diff --git a/docs/examples/decfile_parsing.rst b/docs/examples/decfile_parsing.rst index 9271636e..06c9bf0e 100644 --- a/docs/examples/decfile_parsing.rst +++ b/docs/examples/decfile_parsing.rst @@ -44,4 +44,89 @@ Charge conjugation By default, charge-conjugated decays are automatically included. This behavior can be controlled at parse time. +Command-line validation +----------------------- + +EvtGen ``.dec`` files can be validated without writing Python code: + +.. code-block:: bash + + decaylanguage-validate my-decay-file.dec + decaylanguage-validate path/to/decfiles-directory + +The validator reports stable diagnostic codes. Exact codes or code families can +be disabled, which lets experiments choose their own pre-commit policy: + +.. code-block:: bash + + decaylanguage-validate --ignore=DLW004 my-decay-file.dec + +Use ``decaylanguage-validate --list-diagnostics`` to inspect the currently +available diagnostics. + +Available diagnostics: + +.. list-table:: + :header-rows: 1 + + * - Code + - Name + - Meaning + * - ``DLP001`` + - ``parse-error`` + - The file could not be parsed by ``DecFileParser``. + * - ``DLW001`` + - ``duplicate-decay`` + - A particle has multiple ``Decay`` blocks; only the first is retained. + * - ``DLW002`` + - ``missing-copydecay-source`` + - A ``CopyDecay`` statement references a missing ``Decay`` source. + * - ``DLW003`` + - ``duplicate-cdecay`` + - A particle is defined with both ``Decay`` and ``CDecay``; ``CDecay`` is ignored. + * - ``DLW004`` + - ``missing-cdecay-source`` + - A ``CDecay`` statement has no corresponding ``Decay`` source. + * - ``DLW005`` + - ``self-conjugate-cdecay`` + - A ``CDecay`` statement targets a self-conjugate particle. + * - ``DLW999`` + - ``parser-warning`` + - An otherwise unclassified warning was emitted by ``DecFileParser``. + +When run through pre-commit, failures include the validator output. Parser +errors include the source location and a pointer: + +.. code-block:: text + + Validate EvtGen decay files..............................................Failed + - hook id: decaylanguage-validate + - exit code: 1 + + DecayLanguage: 1 diagnostic(s) in 1 file(s) + tests/data/broken.dec:13:68: DLP001 parse-error: UnexpectedToken: Unexpected token Token('SIGNED_NUMBER', '2') at line 13, column 68. + 13: 0.000044342 Upsilon pi0 pi0 VVPIPI;2 #[Reconstructed PDG2011] + ^ + summary: DLP001=1 + +Parser warnings are reported more compactly: + +.. code-block:: text + + tests/data/example.dec: DLW004 missing-cdecay-source: missing Decay source for CDecay: anti-B0sig + summary: DLW004=1 + +By default, at most 100 diagnostics are printed before the remaining diagnostics +are summarized. Pass ``--max-diagnostics=0`` to print every diagnostic. + +Downstream projects can use the packaged pre-commit hook: + +.. code-block:: yaml + + - repo: https://github.com/scikit-hep/decaylanguage + rev: + hooks: + - id: decaylanguage-validate + args: ["--ignore=DLW004"] + For more detailed examples, see the :doc:`/examples/notebooks/index` section. diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst index e19157dd..fa16f90d 100644 --- a/docs/getting_started/quickstart.rst +++ b/docs/getting_started/quickstart.rst @@ -25,6 +25,24 @@ Use :class:`~decaylanguage.dec.dec.DecFileParser` to parse EvtGen-format ``.dec` See :doc:`/examples/decfile_parsing` for more details. +Validate ``.dec`` files from the command line: + +.. code-block:: bash + + decaylanguage-validate my_decays.dec + decaylanguage-validate path/to/decfiles-directory + +Use ``decaylanguage-validate --list-diagnostics`` to list selectable +diagnostic codes. Downstream pre-commit hooks can disable experiment-specific +codes with options such as ``--ignore=DLW004``. + +On failure, pre-commit shows output such as: + +.. code-block:: text + + tests/data/example.dec: DLW004 missing-cdecay-source: missing Decay source for CDecay: anti-B0sig + summary: DLW004=1 + Building and visualizing decay chains ------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index ca9b3055..924f4a81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,9 @@ test = [ [project.urls] Homepage = "https://github.com/scikit-hep/decaylanguage" +[project.scripts] +decaylanguage-validate = "decaylanguage.dec.validate:main" + [tool.hatch] version.source = "vcs" diff --git a/src/decaylanguage/dec/validate.py b/src/decaylanguage/dec/validate.py new file mode 100644 index 00000000..09d981e9 --- /dev/null +++ b/src/decaylanguage/dec/validate.py @@ -0,0 +1,443 @@ +# Copyright (c) 2018-2026, Eduardo Rodrigues and Henry Schreiner. +# +# Distributed under the 3-clause BSD license, see accompanying file LICENSE +# or https://github.com/scikit-hep/decaylanguage for details. + +"""Command-line validation for EvtGen ``.dec`` files.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import warnings +from collections import Counter +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path + +from .dec import DecFileParser + + +@dataclass(frozen=True) +class DiagnosticRule: + """A selectable validation diagnostic.""" + + code: str + name: str + description: str + pattern: re.Pattern[str] | None = None + + +@dataclass(frozen=True) +class Diagnostic: + """One validation finding for a decay file.""" + + code: str + name: str + path: Path + message: str + line: int | None = None + column: int | None = None + source_line: str | None = None + + +DLP001 = DiagnosticRule( + "DLP001", + "parse-error", + "The file could not be parsed by DecFileParser.", +) +DLW001 = DiagnosticRule( + "DLW001", + "duplicate-decay", + "A particle has multiple Decay blocks; only the first is retained.", + re.compile( + r"The following particle\(s\) is\(are\) redefined in the input \.dec file " + r"with 'Decay': .* All but the first occurrence will be discarded/removed " + r"\.\.\." + ), +) +DLW002 = DiagnosticRule( + "DLW002", + "missing-copydecay-source", + "A CopyDecay statement references a missing Decay source.", + re.compile( + r"Corresponding 'Decay' statement for 'CopyDecay' statement\(s\).* " + r"Skipping creation of these copied decay trees\." + ), +) +DLW003 = DiagnosticRule( + "DLW003", + "duplicate-cdecay", + "A particle is defined with both Decay and CDecay; CDecay is ignored.", + re.compile( + r"The following particles are defined in the input \.dec file with both " + r"'Decay' and 'CDecay': .* The 'CDecay' definition\(s\) will be ignored " + r"\.\.\." + ), +) +DLW004 = DiagnosticRule( + "DLW004", + "missing-cdecay-source", + "A CDecay statement has no corresponding Decay source.", + re.compile( + r"Corresponding 'Decay' statement for 'CDecay' statement\(s\).* " + r"Skipping creation of these charge-conjugate decay trees\." + ), +) +DLW005 = DiagnosticRule( + "DLW005", + "self-conjugate-cdecay", + "A CDecay statement targets a self-conjugate particle.", + re.compile( + r"Found 'CDecay' statement for self-conjugate particle .* " + r"Skipping creation of charge-conjugate decay Tree\." + ), +) +DLW999 = DiagnosticRule( + "DLW999", + "parser-warning", + "An otherwise unclassified warning was emitted by DecFileParser.", +) + +DIAGNOSTIC_RULES = (DLP001, DLW001, DLW002, DLW003, DLW004, DLW005, DLW999) +_RULES_BY_CODE = {rule.code: rule for rule in DIAGNOSTIC_RULES} +_DEFAULT_MAX_DIAGNOSTICS = 100 + + +class Style: + def __init__(self, enabled: bool) -> None: + self.enabled = enabled + + def color(self, text: str, code: str) -> str: + if not self.enabled: + return text + return f"\033[{code}m{text}\033[0m" + + def fail(self, text: str) -> str: + return self.color(text, "31") + + def ok(self, text: str) -> str: + return self.color(text, "32") + + def bold(self, text: str) -> str: + return self.color(text, "1") + + def muted(self, text: str) -> str: + return self.color(text, "2") + + +def validate_files( + paths: Iterable[Path], + *, + ignore: Iterable[str] = (), + additional_decay_models: Iterable[str] = (), +) -> list[Diagnostic]: + """Validate decay files and return non-ignored diagnostics.""" + + ignored = tuple(ignore) + diagnostics: list[Diagnostic] = [] + for path in _iter_decay_files(paths): + diagnostics.extend( + diagnostic + for diagnostic in _validate_file(path, additional_decay_models) + if not _is_ignored(diagnostic.code, ignored) + ) + return diagnostics + + +def _iter_decay_files(paths: Iterable[Path]) -> Iterable[Path]: + for path in paths: + if path.is_dir(): + yield from sorted( + item for item in path.rglob("*") if item.suffix.lower() == ".dec" + ) + else: + yield path + + +def _validate_file( + path: Path, + additional_decay_models: Iterable[str], +) -> list[Diagnostic]: + caught_warnings: list[warnings.WarningMessage] + try: + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + parser = DecFileParser(path) + parser.load_additional_decay_models(*additional_decay_models) + parser.parse() + except Exception as exc: + return [_diagnostic_from_exception(path, exc)] + + return [_diagnostic_from_warning(path, warning) for warning in caught_warnings] + + +def _diagnostic_from_exception(path: Path, exc: Exception) -> Diagnostic: + line = getattr(exc, "line", None) + column = getattr(exc, "column", None) + source_line = None + if isinstance(line, int): + try: + source_line = path.read_text(encoding="utf_8").splitlines()[line - 1] + except Exception: + source_line = None + + return Diagnostic( + code=DLP001.code, + name=DLP001.name, + path=path, + message=f"{exc.__class__.__name__}: {str(exc).splitlines()[0]}", + line=line if isinstance(line, int) else None, + column=column if isinstance(column, int) else None, + source_line=source_line, + ) + + +def _diagnostic_from_warning( + path: Path, + warning: warnings.WarningMessage, +) -> Diagnostic: + message = _normalize_warning_message(warning) + rule = next( + ( + candidate + for candidate in DIAGNOSTIC_RULES + if candidate.pattern is not None and candidate.pattern.fullmatch(message) + ), + DLW999, + ) + return Diagnostic( + code=rule.code, + name=rule.name, + path=path, + message=_compact_warning_message(rule, message), + ) + + +def _normalize_warning_message(warning: warnings.WarningMessage) -> str: + return " ".join(str(warning.message).split()) + + +def _compact_warning_message(rule: DiagnosticRule, message: str) -> str: + if rule is DLW001: + particles = _search_message(message, r"with 'Decay': (?P.*?)!") + if particles is not None: + return f"duplicate Decay block(s): {particles}; later definitions ignored" + if rule is DLW002: + particles = _search_message(message, r"not found: (?P.*?)\.") + if particles is not None: + return f"missing Decay source for CopyDecay: {particles}" + if rule is DLW003: + particles = _search_message(message, r"'CDecay': (?P.*?)!") + if particles is not None: + return f"both Decay and CDecay defined: {particles}; CDecay ignored" + if rule is DLW004: + particles = _search_message(message, r"not found: (?P.*?)\.") + if particles is not None: + return f"missing Decay source for CDecay: {particles}" + if rule is DLW005: + particle = _search_message( + message, + r"self-conjugate particle (?P.*?)\.", + ) + if particle is not None: + return f"CDecay targets self-conjugate particle: {particle}" + return message + + +def _search_message(message: str, pattern: str) -> str | None: + match = re.search(pattern, message) + if match is None: + return None + return match.group("particles") + + +def _is_ignored(code: str, ignored: Sequence[str]) -> bool: + return any(code == item or code.startswith(item) for item in ignored) + + +def _validate_ignore_codes(codes: Iterable[str]) -> list[str]: + prefixes = {rule.code[:3] for rule in DIAGNOSTIC_RULES} + valid_codes = set(_RULES_BY_CODE) + invalid = [ + code for code in codes if code not in valid_codes and code not in prefixes + ] + if invalid: + known = ", ".join(sorted(valid_codes | prefixes)) + msg = f"unknown diagnostic code(s): {', '.join(invalid)}. Known codes: {known}" + raise SystemExit(msg) + return list(codes) + + +def _use_color(color: str) -> bool: + if color == "always": + return True + if color == "never": + return False + return sys.stderr.isatty() and "NO_COLOR" not in os.environ + + +def _print_diagnostics( + diagnostics: list[Diagnostic], + *, + files: Sequence[Path], + show_ok: bool, + color: str, + max_diagnostics: int, +) -> None: + style = Style(_use_color(color)) + if diagnostics: + shown_diagnostics = ( + diagnostics if max_diagnostics == 0 else diagnostics[:max_diagnostics] + ) + sys.stderr.write( + style.fail( + style.bold( + f"DecayLanguage: {len(diagnostics)} diagnostic(s) in " + f"{len({diagnostic.path for diagnostic in diagnostics})} file(s)" + ) + ) + + "\n" + ) + for diagnostic in shown_diagnostics: + sys.stderr.write( + f"{_format_location(diagnostic)}: " + f"{style.fail(diagnostic.code)} " + f"{diagnostic.name}: {diagnostic.message}\n" + ) + if diagnostic.line is not None and diagnostic.source_line is not None: + sys.stderr.write( + style.muted(f" {diagnostic.line}: {diagnostic.source_line}") + + "\n" + ) + if diagnostic.column is not None: + pointer = " " * max(diagnostic.column - 1, 0) + "^" + sys.stderr.write(style.muted(f" {pointer}") + "\n") + hidden = len(diagnostics) - len(shown_diagnostics) + if hidden: + sys.stderr.write( + style.muted( + f"... {hidden} additional diagnostic(s) hidden; " + "use --max-diagnostics=0 to show all" + ) + + "\n" + ) + stats = ", ".join( + f"{code}={count}" + for code, count in sorted(Counter(d.code for d in diagnostics).items()) + ) + sys.stderr.write(style.muted(f"summary: {stats}") + "\n") + return + + if show_ok: + sys.stderr.write(style.ok(f"DecayLanguage: {len(files)} file(s) passed") + "\n") + + +def _format_location(diagnostic: Diagnostic) -> str: + parts = [_display_path(diagnostic.path)] + if diagnostic.line is not None: + parts.append(str(diagnostic.line)) + if diagnostic.column is not None: + parts.append(str(diagnostic.column)) + return ":".join(parts) + + +def _display_path(path: Path) -> str: + try: + return os.path.relpath(path) + except ValueError: + return str(path) + + +def _print_rules() -> None: + for rule in DIAGNOSTIC_RULES: + sys.stdout.write(f"{rule.code} {rule.name}: {rule.description}\n") + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate EvtGen decay files with decaylanguage.DecFileParser.", + ) + parser.add_argument( + "--ignore", + action="append", + default=[], + metavar="CODE", + help=( + "ignore a diagnostic code or code prefix, for example DLW004 or DLW; " + "may be used more than once" + ), + ) + parser.add_argument( + "--additional-decay-model", + action="append", + default=[], + metavar="NAME", + help="allow an experiment-specific EvtGen decay model name", + ) + parser.add_argument( + "--show-ok", + action="store_true", + help="print a message when all files pass", + ) + parser.add_argument( + "--max-diagnostics", + type=int, + default=_DEFAULT_MAX_DIAGNOSTICS, + metavar="N", + help=( + "maximum diagnostics to print before summarising; " + "use 0 to print all diagnostics" + ), + ) + parser.add_argument( + "--color", + choices=("auto", "always", "never"), + default="auto", + help="control colored output", + ) + parser.add_argument( + "--list-diagnostics", + action="store_true", + help="list available diagnostic codes and exit", + ) + parser.add_argument( + "files", + nargs="*", + type=Path, + metavar="PATH", + help="decay files or directories containing .dec/.DEC files", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + if args.list_diagnostics: + _print_rules() + return 0 + if not args.files: + raise SystemExit("at least one decay file must be provided") + + ignored = _validate_ignore_codes(args.ignore) + if args.max_diagnostics < 0: + raise SystemExit("--max-diagnostics must be non-negative") + diagnostics = validate_files( + args.files, + ignore=ignored, + additional_decay_models=args.additional_decay_model, + ) + _print_diagnostics( + diagnostics, + files=args.files, + show_ok=args.show_ok, + color=args.color, + max_diagnostics=args.max_diagnostics, + ) + return 1 if diagnostics else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/dec/test_validate.py b/tests/dec/test_validate.py new file mode 100644 index 00000000..4ade306a --- /dev/null +++ b/tests/dec/test_validate.py @@ -0,0 +1,104 @@ +# Copyright (c) 2018-2026, Eduardo Rodrigues and Henry Schreiner. +# +# Distributed under the 3-clause BSD license, see accompanying file LICENSE +# or https://github.com/scikit-hep/decaylanguage for details. + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from decaylanguage.dec.validate import main, validate_files + +DIR = Path(__file__).parent.resolve() + + +def test_validate_files_reports_duplicate_decay() -> None: + diagnostics = validate_files([DIR / "../data/duplicate-decays.dec"]) + + assert [diagnostic.code for diagnostic in diagnostics] == ["DLW001", "DLW003"] + assert diagnostics[0].message.startswith("duplicate Decay block") + + +def test_validate_files_can_ignore_exact_code() -> None: + diagnostics = validate_files( + [DIR / "../data/duplicate-decays.dec"], + ignore=["DLW001", "DLW003"], + ) + + assert diagnostics == [] + + +def test_validate_files_can_ignore_code_prefix() -> None: + diagnostics = validate_files( + [DIR / "../data/duplicate-decays.dec"], + ignore=["DLW"], + ) + + assert diagnostics == [] + + +def test_validate_files_accepts_directories() -> None: + diagnostics = validate_files( + [DIR / "../data"], + ignore=["DLW", "DLP"], + ) + + assert diagnostics == [] + + +def test_validate_files_reports_parse_errors(tmp_path: Path) -> None: + path = tmp_path / "broken.dec" + path.write_text( + """Decay pi0 +1.0 gamma gamma PHSP; +""", + encoding="utf_8", + ) + + diagnostics = validate_files([path]) + + assert len(diagnostics) == 1 + assert diagnostics[0].code == "DLP001" + assert diagnostics[0].line is not None + + +def test_main_returns_failure_for_diagnostics() -> None: + assert main(["--color=never", str(DIR / "../data/duplicate-decays.dec")]) == 1 + + +def test_main_limits_displayed_diagnostics(capsys: pytest.CaptureFixture[str]) -> None: + assert ( + main( + [ + "--color=never", + "--max-diagnostics=1", + str(DIR / "../data/duplicate-decays.dec"), + ] + ) + == 1 + ) + + captured = capsys.readouterr() + assert "additional diagnostic(s) hidden" in captured.err + assert "summary: DLW001=1, DLW003=1" in captured.err + + +def test_main_returns_success_for_ignored_diagnostics() -> None: + assert ( + main( + [ + "--color=never", + "--ignore=DLW001", + "--ignore=DLW003", + str(DIR / "../data/duplicate-decays.dec"), + ] + ) + == 0 + ) + + +def test_main_rejects_unknown_ignore_code() -> None: + with pytest.raises(SystemExit, match="unknown diagnostic code"): + main(["--ignore=NOPE", str(DIR / "../data/duplicate-decays.dec")])