From 14ebaba070bf84ff44fbaf91d1ee8c7d47f0db7e Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 4 Sep 2026 00:24:29 +0200 Subject: [PATCH 1/2] test: define reporters and --check behavior --- tests/test_architecture.py | 2 +- tests/test_cli.py | 104 ++++++++++++++++++- tests/test_reporters.py | 208 +++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 tests/test_reporters.py diff --git a/tests/test_architecture.py b/tests/test_architecture.py index af0c79a..e779b04 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -41,7 +41,7 @@ } # Top-level modules that wire the pipeline together and may import any layer. -ROOT_MODULES = {"__init__", "__main__", "cli"} +ROOT_MODULES = {"__init__", "__main__", "cli", "engine"} def package_files() -> Iterator[tuple[Path, str, str]]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 124652e..996e58d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,109 @@ +"""CLI contract: ``--emit-ir`` prints PyIR, ``--check`` runs plugins and reports findings. + +Exit codes: 0 clean, 1 findings reported, 2 usage or analysis error. + +The ``--check`` tests are expected to remain red until the engine wires plugins and +reporters into the CLI (docs/architecture.md §28). +""" + +from __future__ import annotations + +import json +from pathlib import Path + from coretrace_python.cli import main +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "plugins" + + +def write(tmp_path: Path, text: str, name: str = "target.py") -> Path: + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return path + def test_cli_requires_an_action(tmp_path, capsys) -> None: - source = tmp_path / "empty.py" - source.write_text("", encoding="utf-8") + source = write(tmp_path, "") assert main([str(source)]) == 2 assert "no action selected" in capsys.readouterr().err + +def test_analysis_errors_exit_with_two(tmp_path, capsys) -> None: + source = write(tmp_path, "def broken(:\n") + assert main(["--emit-ir", str(source)]) == 2 + assert "error:" in capsys.readouterr().err + + +def test_check_reports_findings_from_loaded_plugins(tmp_path, capsys) -> None: + source = write(tmp_path, "def run(code):\n eval(code)\n") + + exit_code = main(["--check", str(source), "--plugins", str(PLUGINS)]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert captured.out == ( + f"{source}:2:5: high dangerous-eval: call to python.builtins.eval executes" + " dynamically built code [run]\n" + "1 finding\n" + ) + + +def test_check_is_clean_when_nothing_is_found(tmp_path, capsys) -> None: + source = write(tmp_path, "def run(code):\n return code\n") + + assert main(["--check", str(source), "--plugins", str(PLUGINS)]) == 0 + assert capsys.readouterr().out == "no findings\n" + + +def test_check_without_plugins_finds_nothing(tmp_path, capsys) -> None: + source = write(tmp_path, "def run(code):\n eval(code)\n") + + assert main(["--check", str(source)]) == 0 + assert capsys.readouterr().out == "no findings\n" + + +def test_check_json_format(tmp_path, capsys) -> None: + source = write(tmp_path, "def run(code):\n eval(code)\n") + + exit_code = main(["--check", str(source), "--plugins", str(PLUGINS), "--format", "json"]) + document = json.loads(capsys.readouterr().out) + + assert exit_code == 1 + assert document["tool"]["name"] == "coretrace-python-analyzer" + assert [f["rule_id"] for f in document["findings"]] == ["dangerous-eval"] + assert document["findings"][0]["location"]["path"] == str(source) + + +def test_check_sarif_format(tmp_path, capsys) -> None: + source = write(tmp_path, "def run(code):\n eval(code)\n") + + exit_code = main(["--check", str(source), "--plugins", str(PLUGINS), "--format", "sarif"]) + document = json.loads(capsys.readouterr().out) + + assert exit_code == 1 + assert document["version"] == "2.1.0" + assert document["runs"][0]["results"][0]["ruleId"] == "dangerous-eval" + + +def test_check_reports_analysis_errors(tmp_path, capsys) -> None: + source = write(tmp_path, "def broken(:\n") + + assert main(["--check", str(source), "--plugins", str(PLUGINS)]) == 2 + assert "error:" in capsys.readouterr().err + + +def test_check_reports_bad_plugin_directories(tmp_path, capsys) -> None: + source = write(tmp_path, "def run():\n pass\n") + broken = tmp_path / "broken" + broken.mkdir() + (broken / "plugin.toml").write_text("name = 'x'\n", encoding="utf-8") + + assert main(["--check", str(source), "--plugins", str(broken)]) == 2 + assert "plugin.toml" in capsys.readouterr().err + + +def test_format_requires_check(tmp_path, capsys) -> None: + source = write(tmp_path, "") + assert main(["--emit-ir", "--format", "json", str(source)]) == 2 + assert "--format" in capsys.readouterr().err diff --git a/tests/test_reporters.py b/tests/test_reporters.py new file mode 100644 index 0000000..f3f354b --- /dev/null +++ b/tests/test_reporters.py @@ -0,0 +1,208 @@ +"""Acceptance tests for the reporter API (``docs/architecture.md`` §23, §28). + +Reporters consume a ``Report`` of normalized findings and render text, JSON or SARIF. +They are pure functions of the report: they never run an analysis and import nothing +from the analysis pipeline. Output is deterministic, sorted by location then rule. + +Expected to remain red until ``coretrace_python.reporters`` exists. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest + +from coretrace_python.findings import FINDING_SCHEMA_VERSION, Confidence, Finding, Severity +from coretrace_python.source import SourceId, SourceSpan + +try: + from coretrace_python.reporters import ( + FORMATS, + Report, + render, + render_json, + render_sarif, + render_text, + ) +except ImportError as error: # pragma: no cover - red until reporters land + MISSING = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_reporters() -> None: + if MISSING is not None: + pytest.fail(f"reporters are not implemented yet: {MISSING}") + + +def finding( + rule: str, + line: int, + column: int = 5, + severity: Severity = Severity.HIGH, + path: str = "app/views.py", + function: str | None = "run", + **metadata: str, +) -> Finding: + return Finding( + rule_id=rule, + message=f"{rule} message", + severity=severity, + confidence=Confidence.HIGH, + span=SourceSpan(SourceId(path), line, column, line, column + 4), + function=function, + metadata=metadata, + ) + + +def report(*findings: Finding) -> Report: + return Report(findings=findings, tool_name="coretrace-python-analyzer", tool_version="0.1.0") + + +# --------------------------------------------------------------------------- report + + +def test_report_sorts_findings_by_location_then_rule() -> None: + late = finding("b-rule", 9) + early = finding("z-rule", 2) + same_line = finding("a-rule", 2) + other_file = finding("a-rule", 1, path="app/a.py") + + ordered = report(late, early, same_line, other_file).findings + + assert ordered == (other_file, same_line, early, late) + + +def test_report_is_immutable() -> None: + import dataclasses + + with pytest.raises(dataclasses.FrozenInstanceError): + report().findings = () # type: ignore[misc] + + +# --------------------------------------------------------------------------- text + + +def test_text_reporter_prints_one_line_per_finding_and_a_summary() -> None: + text = render_text(report(finding("dangerous-eval", 2), finding("sql-injection", 7, severity=Severity.MEDIUM, function=None))) + + assert text == ( + "app/views.py:2:5: high dangerous-eval: dangerous-eval message [run]\n" + "app/views.py:7:5: medium sql-injection: sql-injection message\n" + "2 findings\n" + ) + + +def test_text_reporter_reports_no_findings() -> None: + assert render_text(report()) == "no findings\n" + + +# --------------------------------------------------------------------------- json + + +def test_json_reporter_emits_versioned_normalized_records() -> None: + document = json.loads(render_json(report(finding("dangerous-eval", 2, symbol="python.builtins.eval")))) + + assert document["schema_version"] == FINDING_SCHEMA_VERSION + assert document["tool"] == {"name": "coretrace-python-analyzer", "version": "0.1.0"} + assert document["findings"] == [ + { + "rule_id": "dangerous-eval", + "message": "dangerous-eval message", + "severity": "high", + "confidence": "high", + "location": { + "path": "app/views.py", + "line": 2, + "column": 5, + "end_line": 2, + "end_column": 9, + }, + "function": "run", + "metadata": {"symbol": "python.builtins.eval"}, + } + ] + + +def test_json_reporter_is_deterministic() -> None: + a, b = finding("x", 3), finding("y", 1) + assert render_json(report(a, b)) == render_json(report(b, a)) + assert render_json(report(a, b)).endswith("\n") + + +# --------------------------------------------------------------------------- sarif + + +def test_sarif_reporter_emits_a_single_run_with_rules_and_results() -> None: + document = json.loads( + render_sarif( + report( + finding("dangerous-eval", 2), + finding("dangerous-eval", 5, severity=Severity.MEDIUM), + finding("weak-crypto", 8, severity=Severity.LOW), + ) + ) + ) + + assert document["version"] == "2.1.0" + assert document["$schema"].startswith("https://") + (run,) = document["runs"] + driver = run["tool"]["driver"] + assert driver["name"] == "coretrace-python-analyzer" + assert driver["version"] == "0.1.0" + assert [rule["id"] for rule in driver["rules"]] == ["dangerous-eval", "weak-crypto"] + assert [r["ruleId"] for r in run["results"]] == ["dangerous-eval", "dangerous-eval", "weak-crypto"] + assert [r["level"] for r in run["results"]] == ["error", "warning", "note"] + first = run["results"][0] + assert first["message"] == {"text": "dangerous-eval message"} + assert first["ruleIndex"] == 0 + assert first["locations"] == [ + { + "physicalLocation": { + "artifactLocation": {"uri": "app/views.py"}, + "region": {"startLine": 2, "startColumn": 5, "endLine": 2, "endColumn": 9}, + } + } + ] + + +def test_sarif_levels_follow_severity() -> None: + def level(severity: Severity) -> str: + document = json.loads(render_sarif(report(finding("r", 1, severity=severity)))) + return str(document["runs"][0]["results"][0]["level"]) + + assert level(Severity.CRITICAL) == "error" + assert level(Severity.HIGH) == "error" + assert level(Severity.MEDIUM) == "warning" + assert level(Severity.LOW) == "note" + assert level(Severity.INFO) == "note" + + +# --------------------------------------------------------------------------- dispatch and purity + + +def test_render_dispatches_on_format_name() -> None: + document = report(finding("r", 1)) + + assert set(FORMATS) == {"text", "json", "sarif"} + assert render("text", document) == render_text(document) + assert render("json", document) == render_json(document) + assert render("sarif", document) == render_sarif(document) + with pytest.raises(KeyError): + render("xml", document) + + +def test_reporters_never_touch_the_analysis_pipeline() -> None: + package = Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "reporters" + allowed = {"coretrace_python.findings", "coretrace_python.source", "coretrace_python.reporters"} + + for path in package.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("coretrace_python"): + top = ".".join(node.module.split(".")[:2]) + assert top in allowed, f"{path.name} imports {node.module}" From 1f782c607929f14fd2baa4bb645d03cf4f7853a5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 4 Sep 2026 00:24:29 +0200 Subject: [PATCH 2/2] feat: add reporters, engine composition root and --check --- README.md | 11 +++ src/coretrace_python/cli.py | 76 ++++++++++++++----- src/coretrace_python/engine.py | 66 ++++++++++++++++ src/coretrace_python/reporters/__init__.py | 22 ++++++ src/coretrace_python/reporters/json_format.py | 36 +++++++++ src/coretrace_python/reporters/report.py | 22 ++++++ src/coretrace_python/reporters/sarif.py | 70 +++++++++++++++++ src/coretrace_python/reporters/text.py | 22 ++++++ 8 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 src/coretrace_python/engine.py create mode 100644 src/coretrace_python/reporters/__init__.py create mode 100644 src/coretrace_python/reporters/json_format.py create mode 100644 src/coretrace_python/reporters/report.py create mode 100644 src/coretrace_python/reporters/sarif.py create mode 100644 src/coretrace_python/reporters/text.py diff --git a/README.md b/README.md index 5f38672..19afc9d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,17 @@ python -m pytest python -m ruff check . ``` +## Check a file with plugins + +```bash +coretrace-python-analyzer --check app.py --plugins plugins/ +coretrace-python-analyzer --check app.py --plugins plugins/ --format sarif > report.sarif +``` + +`--plugins` is a directory searched recursively for `plugin.toml` manifests and may be +repeated. `--format` is `text` (default), `json` or `sarif`. Exit status is 0 when nothing +was found, 1 when findings were reported, and 2 on a usage or analysis error. + ## Emit PyIR ```bash diff --git a/src/coretrace_python/cli.py b/src/coretrace_python/cli.py index 9ce54fb..d71cdd5 100644 --- a/src/coretrace_python/cli.py +++ b/src/coretrace_python/cli.py @@ -4,17 +4,40 @@ import sys from pathlib import Path +from coretrace_python import engine +from coretrace_python.analysis import AnalysisError +from coretrace_python.cfg import CFGError from coretrace_python.frontend import HIRBuildError, ParseError, build_hir from coretrace_python.ir.lowering import LoweringError, lower_module from coretrace_python.ir.printer import format_module +from coretrace_python.plugins import IncompatiblePluginError, ManifestError +from coretrace_python.reporters import FORMATS, render from coretrace_python.semantic.imports import ImportResolutionError from coretrace_python.semantic.scopes import ScopeError from coretrace_python.source import SourceManager +EXIT_CLEAN = 0 +EXIT_FINDINGS = 1 +EXIT_ERROR = 2 + +_ANALYSIS_ERRORS = ( + OSError, + UnicodeError, + ParseError, + HIRBuildError, + ImportResolutionError, + ScopeError, + CFGError, + LoweringError, + AnalysisError, + ManifestError, + IncompatiblePluginError, +) + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="coretrace-python-analyzer", + prog=engine.TOOL_NAME, description="Analyze Python source with CoreTrace.", ) parser.add_argument("path", type=Path, help="Python source file to analyze") @@ -28,32 +51,49 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="with --emit-ir, print the static single assignment form", ) + parser.add_argument( + "--check", + action="store_true", + help="run the loaded plugins and report their findings", + ) + parser.add_argument( + "--plugins", + action="append", + type=Path, + default=[], + metavar="DIR", + help="with --check, a directory searched recursively for plugin.toml (repeatable)", + ) + parser.add_argument( + "--format", + choices=sorted(FORMATS), + default=None, + help="with --check, the report format (default: text)", + ) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) - if not args.emit_ir: - print("error: no action selected; pass --emit-ir", file=sys.stderr) - return 2 + if not (args.emit_ir or args.check): + print("error: no action selected; pass --emit-ir or --check", file=sys.stderr) + return EXIT_ERROR + if args.format is not None and not args.check: + print("error: --format only applies to --check", file=sys.stderr) + return EXIT_ERROR try: source = SourceManager().load_file(args.path) - module = lower_module(build_hir(source), ssa=args.ssa) - except ( - OSError, - UnicodeError, - ParseError, - HIRBuildError, - ImportResolutionError, - ScopeError, - LoweringError, - ) as error: + if args.emit_ir: + print(format_module(lower_module(build_hir(source), ssa=args.ssa))) + if args.check: + findings = engine.check(source, args.plugins) + print(render(args.format or "text", engine.report(findings)), end="") + return EXIT_FINDINGS if findings else EXIT_CLEAN + except _ANALYSIS_ERRORS as error: print(f"error: {error}", file=sys.stderr) - return 1 - - print(format_module(module)) - return 0 + return EXIT_ERROR + return EXIT_CLEAN if __name__ == "__main__": diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py new file mode 100644 index 0000000..6c42f84 --- /dev/null +++ b/src/coretrace_python/engine.py @@ -0,0 +1,66 @@ +"""Composition root: the full analysis DAG, plugin loading and one check run. + +This is the only module that knows every layer. The CLI and future hosts call it; +plugins and analyses never import it. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path + +from coretrace_python import __version__ +from coretrace_python.abstract import ConstantPropagation +from coretrace_python.analysis import AnalysisManager, AnyAnalysis +from coretrace_python.cfg import CFGAnalysis, DominanceAnalysis, PostDominanceAnalysis +from coretrace_python.findings import Finding +from coretrace_python.frontend import build_hir +from coretrace_python.hir import nodes +from coretrace_python.ir.defuse import DefUseAnalysis +from coretrace_python.ir.lowering import ModuleIRAnalysis, PyIRAnalysis +from coretrace_python.ir.ssa import SSAAnalysis +from coretrace_python.plugins import PluginRegistry, discover_plugins, run_plugins +from coretrace_python.reporters import Report +from coretrace_python.semantic import SEMANTIC_ANALYSES +from coretrace_python.source import SourceFile +from coretrace_python.taint import SecurityModelAnalysis, SecurityModelRegistry, TaintAnalysis + +TOOL_NAME = "coretrace-python-analyzer" + +ALL_ANALYSES: tuple[AnyAnalysis, ...] = ( + *SEMANTIC_ANALYSES, + CFGAnalysis, + DominanceAnalysis, + PostDominanceAnalysis, + PyIRAnalysis, + ModuleIRAnalysis, + SSAAnalysis, + DefUseAnalysis, + ConstantPropagation, + SecurityModelAnalysis, + TaintAnalysis, +) + + +def build_manager(module: nodes.Module, models: SecurityModelRegistry | None = None) -> AnalysisManager: + """A manager with every engine analysis registered and the security models provided.""" + + manager = AnalysisManager(module) + manager.register(*ALL_ANALYSES) + manager.provide(SecurityModelAnalysis, (models or SecurityModelRegistry()).freeze()) + return manager + + +def check(source: SourceFile, plugin_roots: Sequence[Path]) -> tuple[Finding, ...]: + """Run every plugin found under ``plugin_roots`` against ``source``.""" + + manager = build_manager(build_hir(source)) + registry = PluginRegistry() + for root in plugin_roots: + for loaded in discover_plugins(root, manager): + registry.add(loaded) + return run_plugins(manager, [loaded.plugin for loaded in registry]) + + +def report(findings: Sequence[Finding]) -> Report: + return Report(tuple(findings), TOOL_NAME, __version__) diff --git a/src/coretrace_python/reporters/__init__.py b/src/coretrace_python/reporters/__init__.py new file mode 100644 index 0000000..ed4cbe5 --- /dev/null +++ b/src/coretrace_python/reporters/__init__.py @@ -0,0 +1,22 @@ +"""Reporters render normalized findings and never run an analysis (architecture §28).""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from types import MappingProxyType + +from coretrace_python.reporters.json_format import render_json +from coretrace_python.reporters.report import Report +from coretrace_python.reporters.sarif import render_sarif +from coretrace_python.reporters.text import render_text + +FORMATS: Mapping[str, Callable[[Report], str]] = MappingProxyType( + {"text": render_text, "json": render_json, "sarif": render_sarif} +) + + +def render(format_name: str, report: Report) -> str: + return FORMATS[format_name](report) + + +__all__ = ["FORMATS", "Report", "render", "render_json", "render_sarif", "render_text"] diff --git a/src/coretrace_python/reporters/json_format.py b/src/coretrace_python/reporters/json_format.py new file mode 100644 index 0000000..ad5b94c --- /dev/null +++ b/src/coretrace_python/reporters/json_format.py @@ -0,0 +1,36 @@ +"""JSON reporter: versioned normalized finding records.""" + +from __future__ import annotations + +import json + +from coretrace_python.findings import FINDING_SCHEMA_VERSION, Finding +from coretrace_python.reporters.report import Report + + +def finding_record(finding: Finding) -> dict[str, object]: + span = finding.span + return { + "rule_id": finding.rule_id, + "message": finding.message, + "severity": finding.severity.value, + "confidence": finding.confidence.value, + "location": { + "path": str(span.source_id), + "line": span.start_line, + "column": span.start_column, + "end_line": span.end_line, + "end_column": span.end_column, + }, + "function": finding.function, + "metadata": dict(finding.metadata), + } + + +def render_json(report: Report) -> str: + document = { + "schema_version": FINDING_SCHEMA_VERSION, + "tool": {"name": report.tool_name, "version": report.tool_version}, + "findings": [finding_record(finding) for finding in report.findings], + } + return json.dumps(document, indent=2) + "\n" diff --git a/src/coretrace_python/reporters/report.py b/src/coretrace_python/reporters/report.py new file mode 100644 index 0000000..593083a --- /dev/null +++ b/src/coretrace_python/reporters/report.py @@ -0,0 +1,22 @@ +"""The normalized report every reporter renders (architecture §23, §28).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from coretrace_python.findings import Finding + + +def _order(finding: Finding) -> tuple[str, int, int, str]: + span = finding.span + return (str(span.source_id), span.start_line, span.start_column, finding.rule_id) + + +@dataclass(frozen=True) +class Report: + findings: tuple[Finding, ...] + tool_name: str + tool_version: str + + def __post_init__(self) -> None: + object.__setattr__(self, "findings", tuple(sorted(self.findings, key=_order))) diff --git a/src/coretrace_python/reporters/sarif.py b/src/coretrace_python/reporters/sarif.py new file mode 100644 index 0000000..a0d6e2e --- /dev/null +++ b/src/coretrace_python/reporters/sarif.py @@ -0,0 +1,70 @@ +"""SARIF 2.1.0 reporter.""" + +from __future__ import annotations + +import json + +from coretrace_python.findings import Finding, Severity +from coretrace_python.reporters.report import Report + +SARIF_VERSION = "2.1.0" +SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" + +_LEVELS = { + Severity.CRITICAL: "error", + Severity.HIGH: "error", + Severity.MEDIUM: "warning", + Severity.LOW: "note", + Severity.INFO: "note", +} + + +def _result(finding: Finding, rule_index: int) -> dict[str, object]: + span = finding.span + region: dict[str, int] = {"startLine": span.start_line, "startColumn": span.start_column} + if span.end_line is not None and span.end_column is not None: + region["endLine"] = span.end_line + region["endColumn"] = span.end_column + return { + "ruleId": finding.rule_id, + "ruleIndex": rule_index, + "level": _LEVELS[finding.severity], + "message": {"text": finding.message}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": str(span.source_id)}, + "region": region, + } + } + ], + } + + +def render_sarif(report: Report) -> str: + rule_ids: list[str] = [] + for finding in report.findings: + if finding.rule_id not in rule_ids: + rule_ids.append(finding.rule_id) + document = { + "$schema": SARIF_SCHEMA, + "version": SARIF_VERSION, + "runs": [ + { + "tool": { + "driver": { + "name": report.tool_name, + "version": report.tool_version, + "rules": [ + {"id": rule_id, "shortDescription": {"text": rule_id}} + for rule_id in rule_ids + ], + } + }, + "results": [ + _result(finding, rule_ids.index(finding.rule_id)) for finding in report.findings + ], + } + ], + } + return json.dumps(document, indent=2) + "\n" diff --git a/src/coretrace_python/reporters/text.py b/src/coretrace_python/reporters/text.py new file mode 100644 index 0000000..88003b4 --- /dev/null +++ b/src/coretrace_python/reporters/text.py @@ -0,0 +1,22 @@ +"""Plain-text reporter: one ``path:line:column`` line per finding and a summary.""" + +from __future__ import annotations + +from coretrace_python.reporters.report import Report + + +def render_text(report: Report) -> str: + lines = [] + for finding in report.findings: + span = finding.span + line = ( + f"{span.source_id}:{span.start_line}:{span.start_column}: " + f"{finding.severity.value} {finding.rule_id}: {finding.message}" + ) + if finding.function is not None: + line += f" [{finding.function}]" + lines.append(line) + count = len(report.findings) + summary = "no findings" if count == 0 else f"{count} finding{'s' if count > 1 else ''}" + lines.append(summary) + return "\n".join(lines) + "\n"