diff --git a/shared/python/aci_annotations.py b/shared/python/aci_annotations.py index 4cd6f56..c198722 100644 --- a/shared/python/aci_annotations.py +++ b/shared/python/aci_annotations.py @@ -3,14 +3,18 @@ """GitHub Actions workflow-command annotation emitter for ACI scan reports.""" from __future__ import annotations -from typing import cast - try: from .aci_findings import SEVERITY_CRITICAL, SEVERITY_HIGH, SEVERITY_MEDIUM, SEVERITY_LOW - from .aci_scan import SCOPE_CLASS_RUNTIME_SOURCE, _classify_relative_path + from .aci_report_helpers import ( + gate_scope_classes as _gate_scope_classes, + scope_class as _scope_class, + ) except ImportError: # pragma: no cover - direct script/module import path from aci_findings import SEVERITY_CRITICAL, SEVERITY_HIGH, SEVERITY_MEDIUM, SEVERITY_LOW - from aci_scan import SCOPE_CLASS_RUNTIME_SOURCE, _classify_relative_path + from aci_report_helpers import ( # type: ignore[no-redef] + gate_scope_classes as _gate_scope_classes, + scope_class as _scope_class, + ) _SEVERITY_TO_LEVEL: dict[str, str] = { @@ -21,26 +25,6 @@ } -def _report_map(value: object) -> dict[str, object]: - return cast(dict[str, object], value) if isinstance(value, dict) else {} - - -def _gate_scope_classes(report: dict[str, object]) -> tuple[str, ...]: - scope_rules = _report_map(report.get("scope_rules")) - raw = scope_rules.get("gate_scope_classes") - if not isinstance(raw, list) or not raw: - return (SCOPE_CLASS_RUNTIME_SOURCE,) - values = tuple(str(item) for item in raw if isinstance(item, str) and item) - return values or (SCOPE_CLASS_RUNTIME_SOURCE,) - - -def _scope_class(finding: dict[str, object]) -> str: - explicit = finding.get("scope_class") - if isinstance(explicit, str) and explicit: - return explicit - return _classify_relative_path(str(finding.get("target_file") or "")) - - def _encode_param(value: str) -> str: """Percent-encode characters that break GitHub Actions workflow command parsing.""" return ( diff --git a/shared/python/aci_github_summary.py b/shared/python/aci_github_summary.py index 7269ba5..3726f7d 100644 --- a/shared/python/aci_github_summary.py +++ b/shared/python/aci_github_summary.py @@ -3,11 +3,10 @@ """GitHub-friendly markdown summary emitter for ACI reports.""" from __future__ import annotations -from typing import cast - - -def _report_map(value: object) -> dict[str, object]: - return cast(dict[str, object], value) if isinstance(value, dict) else {} +try: + from .aci_report_helpers import report_map as _report_map +except ImportError: # pragma: no cover - direct script/module import path + from aci_report_helpers import report_map as _report_map # type: ignore[no-redef] def build_github_summary_markdown(report: dict[str, object]) -> str: diff --git a/shared/python/aci_report_helpers.py b/shared/python/aci_report_helpers.py new file mode 100644 index 0000000..1620cad --- /dev/null +++ b/shared/python/aci_report_helpers.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Shared report-reading helpers for the emit/view surfaces. + +These three helpers were copy-pasted, byte-identical, across the SARIF, +annotations, GitHub-summary, and report-view emitters. ACI's own CI-05 +(copy-paste) detector flagged the duplication on a self-scan; this module is the +shared abstraction it asked for. Each emitter imports these (aliased to its +former private names) instead of redefining them. +""" +from __future__ import annotations + +from typing import cast + +try: + from .aci_scan import SCOPE_CLASS_RUNTIME_SOURCE, _classify_relative_path +except ImportError: # pragma: no cover - direct script/module import path + from aci_scan import SCOPE_CLASS_RUNTIME_SOURCE, _classify_relative_path # type: ignore[no-redef] + + +def report_map(value: object) -> dict[str, object]: + """Coerce an arbitrary report value to a dict, or {} if it is not one.""" + return cast(dict[str, object], value) if isinstance(value, dict) else {} + + +def gate_scope_classes(report: dict[str, object]) -> tuple[str, ...]: + """The report's gate scope classes, defaulting to runtime-source.""" + scope_rules = report_map(report.get("scope_rules")) + raw = scope_rules.get("gate_scope_classes") + if not isinstance(raw, list) or not raw: + return (SCOPE_CLASS_RUNTIME_SOURCE,) + values = tuple(str(item) for item in raw if isinstance(item, str) and item) + return values or (SCOPE_CLASS_RUNTIME_SOURCE,) + + +def scope_class(finding: dict[str, object]) -> str: + """A finding's explicit scope_class, else one derived from its target file.""" + explicit = finding.get("scope_class") + if isinstance(explicit, str) and explicit: + return explicit + return _classify_relative_path(str(finding.get("target_file") or "")) diff --git a/shared/python/aci_report_view.py b/shared/python/aci_report_view.py index b0e36eb..6cd6578 100644 --- a/shared/python/aci_report_view.py +++ b/shared/python/aci_report_view.py @@ -27,6 +27,7 @@ SCOPE_CLASS_TESTS, _classify_relative_path, ) + from .aci_report_helpers import report_map as _report_map, gate_scope_classes as _gate_scope_classes except ImportError: # pragma: no cover - direct script/module import path from aci_findings import ( # type: ignore[no-redef] LANE_EXTERNAL_ANALYZER, @@ -48,6 +49,7 @@ SCOPE_CLASS_TESTS, _classify_relative_path, ) + from aci_report_helpers import report_map as _report_map, gate_scope_classes as _gate_scope_classes # type: ignore[no-redef] SUPPORTED_REPORT_SCOPE_CLASSES: tuple[str, ...] = ( @@ -82,10 +84,6 @@ } -def _report_map(value: object) -> dict[str, object]: - return cast(dict[str, object], value) if isinstance(value, dict) else {} - - def _report_rows(value: object) -> list[dict[str, object]]: if not isinstance(value, list): return [] @@ -155,18 +153,6 @@ def _top_counts(values: list[str], *, limit: int = 5) -> list[dict[str, object]] return [{"name": name, "count": count} for name, count in counter.most_common(limit)] -def _gate_scope_classes(report: dict[str, object]) -> tuple[str, ...]: - scope_rules = _report_map(report.get("scope_rules")) - gate_scope_classes = scope_rules.get("gate_scope_classes") - if not isinstance(gate_scope_classes, list) or not gate_scope_classes: - return (SCOPE_CLASS_RUNTIME_SOURCE,) - return tuple( - str(item) - for item in gate_scope_classes - if isinstance(item, str) and item - ) or (SCOPE_CLASS_RUNTIME_SOURCE,) - - def _filter_findings( findings: list[dict[str, object]], scope_classes: tuple[str, ...], diff --git a/shared/python/aci_sarif.py b/shared/python/aci_sarif.py index 0081bc9..63e9e0c 100644 --- a/shared/python/aci_sarif.py +++ b/shared/python/aci_sarif.py @@ -7,10 +7,10 @@ try: from .aci_findings import SEVERITY_CRITICAL, SEVERITY_HIGH, SEVERITY_MEDIUM, SEVERITY_LOW - from .aci_scan import SCOPE_CLASS_RUNTIME_SOURCE, _classify_relative_path + from .aci_report_helpers import gate_scope_classes as _gate_scope_classes, scope_class as _scope_class except ImportError: # pragma: no cover - direct script/module import path from aci_findings import SEVERITY_CRITICAL, SEVERITY_HIGH, SEVERITY_MEDIUM, SEVERITY_LOW - from aci_scan import SCOPE_CLASS_RUNTIME_SOURCE, _classify_relative_path + from aci_report_helpers import gate_scope_classes as _gate_scope_classes, scope_class as _scope_class # type: ignore[no-redef] SEVERITY_TO_LEVEL = { @@ -21,26 +21,6 @@ } -def _report_map(value: object) -> dict[str, object]: - return cast(dict[str, object], value) if isinstance(value, dict) else {} - - -def _gate_scope_classes(report: dict[str, object]) -> tuple[str, ...]: - scope_rules = _report_map(report.get("scope_rules")) - raw = scope_rules.get("gate_scope_classes") - if not isinstance(raw, list) or not raw: - return (SCOPE_CLASS_RUNTIME_SOURCE,) - values = tuple(str(item) for item in raw if isinstance(item, str) and item) - return values or (SCOPE_CLASS_RUNTIME_SOURCE,) - - -def _scope_class(finding: dict[str, object]) -> str: - explicit = finding.get("scope_class") - if isinstance(explicit, str) and explicit: - return explicit - return _classify_relative_path(str(finding.get("target_file") or "")) - - def build_sarif_report(report: dict[str, object]) -> dict[str, object]: raw_findings = report.get("findings") or [] findings: list[dict[str, object]] = raw_findings if isinstance(raw_findings, list) else []