From c335b57804b81761f577789aa6dc13abb1e3ec7a Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Thu, 29 Sep 2022 21:36:45 +0100 Subject: [PATCH] Adds summary stats about found violations (#2495) Fixes: #2110 --- .flake8 | 1 + playbook.yml | 9 +-- src/ansiblelint/app.py | 113 +++++++++++++++++++++++------- src/ansiblelint/data/profiles.yml | 2 +- src/ansiblelint/stats.py | 36 ++++++++++ 5 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 src/ansiblelint/stats.py diff --git a/.flake8 b/.flake8 index ef49da38ef..c7eef6417b 100644 --- a/.flake8 +++ b/.flake8 @@ -40,6 +40,7 @@ max-line-length = 100 extend-ignore = E203, E501, + C901, # complexity is also measured by pylint: too-many-branches DAR104, # We use type annotations instead DAR301, # https://github.com/terrencepreilly/darglint/issues/165 F401, # duplicate of pylint W0611 (unused-import) diff --git a/playbook.yml b/playbook.yml index 16d9cb199f..226c1deb4e 100644 --- a/playbook.yml +++ b/playbook.yml @@ -1,6 +1,7 @@ --- -- hosts: localhost +- name: Example + hosts: localhost tasks: - - shell: - cmd: | - if [ $FOO != false ]; then + - name: Play name + debug: + msg: "Hello {{ ansible_facts.env | list }}!" diff --git a/src/ansiblelint/app.py b/src/ansiblelint/app.py index 95ebe02f03..31be60c49c 100644 --- a/src/ansiblelint/app.py +++ b/src/ansiblelint/app.py @@ -3,18 +3,21 @@ import logging import os -from dataclasses import dataclass from functools import lru_cache from typing import TYPE_CHECKING, Any from ansible_compat.runtime import Runtime +from rich.markup import escape +from rich.table import Table from ansiblelint import formatters from ansiblelint._mockings import _perform_mockings from ansiblelint.color import console, console_stderr, render_yaml +from ansiblelint.config import PROFILES from ansiblelint.config import options as default_options -from ansiblelint.constants import SUCCESS_RC, VIOLATIONS_FOUND_RC +from ansiblelint.constants import RULE_DOC_URL, SUCCESS_RC, VIOLATIONS_FOUND_RC from ansiblelint.errors import MatchError +from ansiblelint.stats import SummarizedResults, TagStats if TYPE_CHECKING: from argparse import Namespace @@ -28,21 +31,6 @@ _logger = logging.getLogger(__package__) -@dataclass -class SummarizedResults: - """The statistics about an ansible-lint run.""" - - failures: int = 0 - warnings: int = 0 - fixed_failures: int = 0 - fixed_warnings: int = 0 - - @property - def fixed(self) -> int: - """Get total fixed count.""" - return self.fixed_failures + self.fixed_warnings - - class App: """App class represents an execution of the linter.""" @@ -101,27 +89,34 @@ def render_matches(self, matches: list[MatchError]) -> None: def count_results(self, matches: list[MatchError]) -> SummarizedResults: """Count failures and warnings in matches.""" - failures = 0 - warnings = 0 - fixed_failures = 0 - fixed_warnings = 0 + result = SummarizedResults() + for match in matches: # tag can include a sub-rule id: `yaml[document-start]` # rule.id is the generic rule id: `yaml` # *rule.tags is the list of the rule's tags (categories): `style` + if match.tag not in result.tag_stats: + result.tag_stats[match.tag] = TagStats( + tag=match.tag, count=1, associated_tags=match.rule.tags + ) + else: + result.tag_stats[match.tag].count += 1 + if {match.tag, match.rule.id, *match.rule.tags}.isdisjoint( self.options.warn_list ): + # not in warn_list if match.fixed: - fixed_failures += 1 + result.fixed_failures += 1 else: - failures += 1 + result.failures += 1 else: + result.tag_stats[match.tag].warning = True if match.fixed: - fixed_warnings += 1 + result.fixed_warnings += 1 else: - warnings += 1 - return SummarizedResults(failures, warnings, fixed_failures, fixed_warnings) + result.warnings += 1 + return result @staticmethod def count_lintables(files: set[Lintable]) -> tuple[int, int]: @@ -207,10 +202,32 @@ def report_outcome(self, result: LintResult, mark_as_success: bool = False) -> i return VIOLATIONS_FOUND_RC @staticmethod - def report_summary( + def report_summary( # pylint: disable=too-many-branches,too-many-locals summary: SummarizedResults, changed_files_count: int, files_count: int ) -> None: """Report match and file counts.""" + # sort the stats by profiles + idx = 0 + rule_order = {} + + for profile, profile_config in PROFILES.items(): + for rule in profile_config["rules"]: + # print(profile, rule) + rule_order[rule] = (idx, profile) + idx += 1 + _logger.debug("Determined rule-profile order: %s", rule_order) + failed_profiles = set() + for tag, tag_stats in summary.tag_stats.items(): + if tag in rule_order: + tag_stats.order, tag_stats.profile = rule_order.get(tag, (idx, "")) + elif "[" in tag: + tag_stats.order, tag_stats.profile = rule_order.get( + tag.split("[")[0], (idx, "") + ) + if tag_stats.profile: + failed_profiles.add(tag_stats.profile) + summary.sort() + if changed_files_count: console_stderr.print(f"Modified {changed_files_count} files.") @@ -220,6 +237,48 @@ def report_summary( msg += f", and fixed {summary.fixed} issue(s)" msg += f" on {files_count} files." + # determine which profile passed + summary.passed_profile = "" + passed_profile_count = 0 + for profile in PROFILES.keys(): + if profile in failed_profiles: + break + if profile != summary.passed_profile: + summary.passed_profile = profile + passed_profile_count += 1 + + if summary.tag_stats: + table = Table( + title="Rule Violation Summary", + collapse_padding=True, + box=None, + show_lines=False, + ) + table.add_column("count", justify="right") + table.add_column("tag") + table.add_column("profile") + table.add_column("rule associated tags") + for tag, stats in summary.tag_stats.items(): + table.add_row( + str(stats.count), + f"[link={RULE_DOC_URL}{ tag.split('[')[0] }]{escape(tag)}[/link]", + stats.profile, + f"{', '.join(stats.associated_tags)}{' (warning)' if stats.warning else ''}", + style="yellow" if stats.warning else "red", + ) + # rate stars for the top 5 profiles (min would not get + rating = 5 - (len(PROFILES.keys()) - passed_profile_count) + if 0 < rating < 6: + stars = f"Rated as {rating}/5 stars." + else: + stars = "No rating." + + console_stderr.print(table) + console_stderr.print() + + if summary.passed_profile: + msg += f" Code passed [white bold]{summary.passed_profile}[/] profile. {stars}" + console_stderr.print(msg) diff --git a/src/ansiblelint/data/profiles.yml b/src/ansiblelint/data/profiles.yml index 1072dc87ce..9204f28c6b 100644 --- a/src/ansiblelint/data/profiles.yml +++ b/src/ansiblelint/data/profiles.yml @@ -35,6 +35,7 @@ basic: partial-become: playbook-extension: role-name: + schema: # can cover lots of rules, but not really be able to give best error messages name: var-naming: yaml: @@ -54,7 +55,6 @@ moderate: name[casing]: no-shorthand: # schema-related url: https://github.com/ansible/ansible-lint/issues/2117 - schema: # can cover lots of rules, but not really be able to give best error messages spell-var-name: url: https://github.com/ansible/ansible-lint/issues/2168 safety: diff --git a/src/ansiblelint/stats.py b/src/ansiblelint/stats.py new file mode 100644 index 0000000000..67320b862f --- /dev/null +++ b/src/ansiblelint/stats.py @@ -0,0 +1,36 @@ +"""Module hosting functionality about reporting.""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(order=True) +class TagStats: + """Tag statistics.""" + + order: int = 0 # to be computed based on rule's profile + tag: str = "" # rule effective id (can be multiple tags per rule id) + count: int = 0 # total number of occurrences + warning: bool = False # set true if listed in warn_list + profile: str = "" + associated_tags: list[str] = field(default_factory=list) + + +class SummarizedResults: + """The statistics about an ansible-lint run.""" + + failures: int = 0 + warnings: int = 0 + fixed_failures: int = 0 + fixed_warnings: int = 0 + tag_stats: dict[str, TagStats] = {} + passed_profile: str = "" + + @property + def fixed(self) -> int: + """Get total fixed count.""" + return self.fixed_failures + self.fixed_warnings + + def sort(self) -> None: + """Sort tag stats by tag name.""" + self.tag_stats = dict(sorted(self.tag_stats.items(), key=lambda t: t[1]))