diff --git a/docs/tools/report-converter.md b/docs/tools/report-converter.md index f310264914..365025b977 100644 --- a/docs/tools/report-converter.md +++ b/docs/tools/report-converter.md @@ -22,6 +22,7 @@ a CodeChecker server. * [TSLint](#tslint) * [Golint](#golint) * [Pyflakes](#pyflakes) + * [Ruff](#ruff) * [PVS-Studio](#PVS-Studio) * [Markdownlint](#markdownlint) * [Coccinelle](#coccinelle) @@ -132,6 +133,13 @@ Supported analyzers: ## Supported analyzer outputs +The list below can go out of sync with the actual code. For an always +up-to-date usage example for a given analyzer straight from the source, run: +```sh +report-converter --example +# e.g. report-converter --example ruff +``` + ### Sanitizers #### Undefined Behaviour Sanitizer [UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) @@ -409,6 +417,27 @@ report-converter -t pyflakes -o ./codechecker_pyflakes_reports ./pyflakes_report CodeChecker store ./codechecker_pyflakes_reports -n pyflakes ``` +### Ruff +[Ruff](https://docs.astral.sh/ruff/) is an extremely fast static analysis +tool (linter) for `Python` code. + +The recommended way of running Ruff is to generate a `json` output file. + +The following example shows you how to run Ruff and store the results +found by Ruff to the CodeChecker database. + +```sh +# Run ruff and generate a json output file. +ruff check --output-format json /path/to/my/project > ./ruff_reports.json + +# Use 'report-converter' to create a CodeChecker report directory from the +# analyzer result of ruff. +report-converter -t ruff -o ./codechecker_ruff_reports ./ruff_reports.json + +# Store the ruff reports with CodeChecker. +CodeChecker store ./codechecker_ruff_reports -n ruff +``` + ### PVS-Studio [PVS-Studio](https://pvs-studio.com/en) is a static analyzer on guard of code quality, security (SAST), and code safety for C, C++, C# and Java. diff --git a/tools/report-converter/codechecker_report_converter/analyzers/analyzer_result.py b/tools/report-converter/codechecker_report_converter/analyzers/analyzer_result.py index 1dd7f44fb6..24dffc6bd4 100644 --- a/tools/report-converter/codechecker_report_converter/analyzers/analyzer_result.py +++ b/tools/report-converter/codechecker_report_converter/analyzers/analyzer_result.py @@ -35,6 +35,15 @@ class AnalyzerResultBase(metaclass=ABCMeta): # Link to the official analyzer website. URL: str = '' + # An example shell command (or short sequence of commands) showing how + # to produce an analyzer result file that this parser can consume, and + # how to feed it to 'report-converter'. Every new parser MUST provide + # this so that usage instructions live next to the code and can't + # silently drift out of sync with docs/tools/report-converter.md (see + # https://github.com/Ericsson/codechecker/issues/4992). Users can view + # it directly with 'report-converter --example '. + EXAMPLE_CMD: str = '' + def transform( self, analyzer_result_file_paths: Iterable[str], diff --git a/tools/report-converter/codechecker_report_converter/analyzers/ruff/analyzer_result.py b/tools/report-converter/codechecker_report_converter/analyzers/ruff/analyzer_result.py index 9921b7a782..08b04b272f 100644 --- a/tools/report-converter/codechecker_report_converter/analyzers/ruff/analyzer_result.py +++ b/tools/report-converter/codechecker_report_converter/analyzers/ruff/analyzer_result.py @@ -27,6 +27,16 @@ class AnalyzerResult(AnalyzerResultBase): TOOL_NAME = 'ruff' NAME = 'ruff' URL = 'https://docs.astral.sh/ruff/' + EXAMPLE_CMD = """\ +# Run ruff and generate a json output file. +ruff check --output-format json /path/to/my/project > ./ruff_reports.json + +# Use 'report-converter' to create a CodeChecker report directory from the +# analyzer result of ruff. +report-converter -t ruff -o ./codechecker_ruff_reports ./ruff_reports.json + +# Store the ruff reports with CodeChecker. +CodeChecker store ./codechecker_ruff_reports -n ruff""" def get_reports(self, file_path: str) -> List[Report]: """ Get reports from the given analyzer result. """ diff --git a/tools/report-converter/codechecker_report_converter/cli.py b/tools/report-converter/codechecker_report_converter/cli.py index fe73a71cc4..199cf1d029 100755 --- a/tools/report-converter/codechecker_report_converter/cli.py +++ b/tools/report-converter/codechecker_report_converter/cli.py @@ -53,6 +53,31 @@ class RawDescriptionDefaultHelpFormatter( descriptions. """ +class PrintExampleCmdAction(argparse.Action): + """ + Argparse action which immediately prints a usage example for the given + analyzer TYPE and exits - similarly to the built-in '--version'/'--help' + actions, this bypasses this program's other required arguments (input, + --output, --type), since the user is only asking for documentation, not + actually running a conversion. + """ + + def __call__(self, parser, namespace, values, option_string=None): + analyzer_parser = supported_converters[values] + + if not analyzer_parser.EXAMPLE_CMD: + LOG.warning( + "No usage example is available yet for '%s'. Please see " + "docs/tools/report-converter.md for more information.", + values) + else: + print(f"Example commands to produce a '{values}' output which " + "'report-converter' can parse:\n") + print(analyzer_parser.EXAMPLE_CMD) + + parser.exit() + + # Load supported converters dynamically. supported_converters = {} analyzers_dir_path = os.path.join(os.path.dirname( @@ -263,6 +288,17 @@ def main(): for tool_name in sorted(supported_converters)])), formatter_class=RawDescriptionDefaultHelpFormatter ) + + parser.add_argument('--example', + action=PrintExampleCmdAction, + metavar='TYPE', + choices=supported_converters, + default=argparse.SUPPRESS, + help="Print an example command showing how to " + "produce an analyzer result file of the given " + "TYPE that 'report-converter' can parse, then " + "exit. Currently supported types are: " + + ', '.join(sorted(supported_converters)) + ".") __add_arguments_to_parser(parser) args = parser.parse_args() diff --git a/tools/report-converter/tests/unit/test_cli.py b/tools/report-converter/tests/unit/test_cli.py new file mode 100644 index 0000000000..f1fe2a1128 --- /dev/null +++ b/tools/report-converter/tests/unit/test_cli.py @@ -0,0 +1,115 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +""" +Tests for the report-converter CLI, specifically the '--example' flag and +the EXAMPLE_CMD interface it relies on (see +https://github.com/Ericsson/codechecker/issues/4992). +""" + +import io +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from codechecker_report_converter import cli + + +# TOOL_NAMEs of analyzers that do not yet provide an EXAMPLE_CMD. +# +# This list must not grow: any newly added parser is required to define a +# non-empty EXAMPLE_CMD (see AnalyzerResultBase.EXAMPLE_CMD). Existing +# parsers on this list predate that requirement and should have one added +# as a follow-up; when a parser is updated, remove it from this list. +LEGACY_ANALYZERS_WITHOUT_EXAMPLE_CMD = { + 'clang-tidy', 'clang-tidy-yaml', 'coccinelle', 'cppcheck', 'cpplint', + 'eslint', 'gcc', 'golint', 'fbinfer', 'jscpd', 'kernel-doc', 'mdl', + 'pmd', 'pvs-studio', 'pyflakes', 'pylint', 'roslynator', 'smatch', + 'sparse', 'sphinx', 'spotbugs', 'tslint', + 'asan', 'lsan', 'msan', 'tsan', 'ubsan', +} + + +class ExampleCmdInterfaceTest(unittest.TestCase): + """ + Ensures new analyzer parsers can't be added without a usage example, + while allowing existing ones to be migrated gradually. + """ + + def test_new_analyzers_must_define_example_cmd(self): + for tool_name, analyzer_result in cli.supported_converters.items(): + if tool_name in LEGACY_ANALYZERS_WITHOUT_EXAMPLE_CMD: + continue + + self.assertTrue( + analyzer_result.EXAMPLE_CMD, + f"'{tool_name}' must define a non-empty EXAMPLE_CMD class " + "attribute (see AnalyzerResultBase.EXAMPLE_CMD) so users " + "can discover how to produce compatible input via " + "'report-converter --example {tool_name}'.") + + def test_legacy_exemption_list_has_no_stale_entries(self): + """ + Keeps the exemption list itself honest: every entry must still + correspond to a supported analyzer that genuinely lacks an + EXAMPLE_CMD. Once a legacy parser is updated, it must be removed + from this list rather than left behind. + """ + for tool_name in LEGACY_ANALYZERS_WITHOUT_EXAMPLE_CMD: + self.assertIn(tool_name, cli.supported_converters) + self.assertFalse( + cli.supported_converters[tool_name].EXAMPLE_CMD, + f"'{tool_name}' now defines an EXAMPLE_CMD - please remove " + "it from LEGACY_ANALYZERS_WITHOUT_EXAMPLE_CMD.") + + +class ExampleFlagTest(unittest.TestCase): + """ Tests for the 'report-converter --example TYPE' flag. """ + + def test_example_flag_prints_example_and_exits_zero(self): + """ + For an analyzer with an EXAMPLE_CMD, '--example' should print it + and exit successfully, without requiring the tool's other + required arguments (input, --output, --type). + """ + out = io.StringIO() + with patch('sys.argv', ['report-converter', '--example', 'ruff']), \ + redirect_stdout(out): + with self.assertRaises(SystemExit) as ctx: + cli.main() + + self.assertEqual(ctx.exception.code, 0) + self.assertIn('ruff check', out.getvalue()) + self.assertIn('report-converter -t ruff', out.getvalue()) + + def test_example_flag_on_legacy_analyzer_does_not_crash(self): + """ + For an analyzer that doesn't have an EXAMPLE_CMD yet, '--example' + should exit cleanly (with a warning) instead of crashing. + """ + with patch('sys.argv', ['report-converter', '--example', 'pylint']): + with self.assertRaises(SystemExit) as ctx: + cli.main() + + self.assertEqual(ctx.exception.code, 0) + + def test_example_flag_rejects_unknown_analyzer(self): + """ + An unsupported TYPE should be rejected the same way argparse + rejects any other invalid 'choices' value. + """ + argv = ['report-converter', '--example', 'not_a_real_analyzer'] + with patch('sys.argv', argv): + with self.assertRaises(SystemExit) as ctx: + cli.main() + + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main()