rai-audit-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+ env/
12
+
13
+ # Packaging / build
14
+ build/
15
+ dist/
16
+ *.egg-info/
17
+ *.egg
18
+ pip-wheel-metadata/
19
+ *.whl
20
+
21
+ # Test / coverage / type checking
22
+ .pytest_cache/
23
+ .coverage
24
+ .coverage.*
25
+ htmlcov/
26
+ .mypy_cache/
27
+ .ruff_cache/
28
+ .hypothesis/
29
+
30
+ # Audit runtime artifacts
31
+ .rai-audit/
32
+ *_audit_report.html
33
+ *_audit_report.md
34
+ *_audit_report.json
35
+ loan_audit_report.*
36
+
37
+ # Environment / secrets
38
+ .env
39
+ .env.*
40
+ !.env.example
41
+
42
+ # IDE / editor
43
+ .vscode/
44
+ .idea/
45
+ *.swp
46
+ *.swo
47
+ *~
48
+
49
+ # OS
50
+ .DS_Store
51
+ Thumbs.db
52
+ Desktop.ini
53
+
54
+ # Typical dev data
55
+ dev-data/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sai Teja Erukude
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: rai-audit-core
3
+ Version: 0.1.0
4
+ Summary: Shared audit engine, findings, reports, and CLI for the RAI Audit Kit
5
+ Author: Sai Teja Erukude
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Sai Teja Erukude
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: audit,fairness,responsible-ai,trustworthy-ai
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.10
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
38
+ Requires-Python: >=3.10
39
+ Requires-Dist: rich>=13.0
40
+ Requires-Dist: typer>=0.12
41
+ Description-Content-Type: text/markdown
42
+
43
+ # rai-audit-core
@@ -0,0 +1 @@
1
+ # rai-audit-core
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rai-audit-core"
7
+ version = "0.1.0"
8
+ description = "Shared audit engine, findings, reports, and CLI for the RAI Audit Kit"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "Sai Teja Erukude" }]
12
+ requires-python = ">=3.10"
13
+ keywords = ["responsible-ai", "audit", "fairness", "trustworthy-ai"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ ]
25
+ dependencies = [
26
+ "typer>=0.12",
27
+ "rich>=13.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ rai-audit = "rai_audit.core.cli:app"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/rai_audit"]
35
+
36
+ [tool.hatch.build.targets.wheel.sources]
37
+ "src" = ""
@@ -0,0 +1,19 @@
1
+ from rai_audit.core.findings import (
2
+ AuditFinding,
3
+ AuditReport,
4
+ CategoryRisk,
5
+ RemediationEffort,
6
+ RiskLevel,
7
+ Severity,
8
+ )
9
+ from rai_audit.core.scoring import compute_risk_matrix
10
+
11
+ __all__ = [
12
+ "AuditFinding",
13
+ "AuditReport",
14
+ "CategoryRisk",
15
+ "RemediationEffort",
16
+ "RiskLevel",
17
+ "Severity",
18
+ "compute_risk_matrix",
19
+ ]
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from rai_audit.core.history import diff_runs, list_runs, load_run, render_diff_text
13
+ from rai_audit.core.scoring import gate_check
14
+
15
+ app = typer.Typer(
16
+ name="rai-audit",
17
+ help="Responsible AI (RAI) Audit Kit — evidence-grade audits for responsible AI systems.",
18
+ no_args_is_help=True,
19
+ )
20
+ console = Console()
21
+
22
+
23
+ @app.command()
24
+ def init(
25
+ project: str = typer.Option("my-project", help="Project name"),
26
+ output: Path = typer.Option(Path("audit.yaml"), help="Output config path"),
27
+ ) -> None:
28
+ """Scaffold a starter audit.yaml config file."""
29
+ config = f"""project:
30
+ name: {project}
31
+ owner: ""
32
+ version: 0.1.0
33
+
34
+ audit:
35
+ output_dir: ./audit-report
36
+ report_formats:
37
+ - html
38
+ - markdown
39
+ - json
40
+
41
+ checks:
42
+ fairness:
43
+ enabled: true
44
+ max_demographic_parity_difference: 0.10
45
+ max_equal_opportunity_difference: 0.10
46
+ robustness:
47
+ enabled: true
48
+ data_quality:
49
+ enabled: true
50
+ drift:
51
+ enabled: false
52
+
53
+ gate:
54
+ min_score: null
55
+ fail_on_critical: true
56
+ """
57
+ output.write_text(config, encoding="utf-8")
58
+ console.print(f"[green]✓[/green] Created {output}")
59
+
60
+
61
+ @app.command()
62
+ def report(
63
+ input: Path = typer.Argument(..., help="Path to a saved audit JSON run"),
64
+ format: str = typer.Option("html", help="Output format: html | markdown | json"),
65
+ output: Optional[Path] = typer.Option(None, help="Output file path"),
66
+ ) -> None:
67
+ """Render a report from a saved audit run JSON file."""
68
+ if not input.exists():
69
+ console.print(f"[red]Error:[/red] {input} not found")
70
+ raise typer.Exit(1)
71
+
72
+ run = load_run(input)
73
+
74
+ if output is None:
75
+ output = input.with_suffix(f".{format}" if format != "json" else ".out.json")
76
+
77
+ if format == "json":
78
+ output.write_text(json.dumps(run, indent=2), encoding="utf-8")
79
+ elif format == "markdown":
80
+ from rai_audit.core.findings import AuditReport, AuditFinding, CategoryRisk, Severity, RiskLevel, RemediationEffort
81
+ from rai_audit.core.report import render_markdown
82
+ report_obj = _dict_to_report(run)
83
+ output.write_text(render_markdown(report_obj), encoding="utf-8")
84
+ elif format == "html":
85
+ from rai_audit.core.report import render_html
86
+ report_obj = _dict_to_report(run)
87
+ output.write_text(render_html(report_obj), encoding="utf-8")
88
+ else:
89
+ console.print(f"[red]Unknown format:[/red] {format}")
90
+ raise typer.Exit(1)
91
+
92
+ console.print(f"[green]✓[/green] Report written to {output}")
93
+
94
+
95
+ @app.command()
96
+ def gate(
97
+ input: Path = typer.Argument(..., help="Path to saved audit JSON run"),
98
+ min_score: Optional[float] = typer.Option(None, help="Minimum required score"),
99
+ fail_on_critical: bool = typer.Option(True, help="Fail if any critical findings exist"),
100
+ output_json: Optional[Path] = typer.Option(None, help="Write gate result to JSON file"),
101
+ ) -> None:
102
+ """
103
+ CI/CD deployment gate. Exits with code 1 on failure, 0 on pass.
104
+ """
105
+ if not input.exists():
106
+ console.print(f"[red]Error:[/red] {input} not found")
107
+ raise typer.Exit(1)
108
+
109
+ run = load_run(input)
110
+ passed, reason = gate_check(run, min_score=min_score, fail_on_critical=fail_on_critical)
111
+
112
+ risk_matrix = {r["category"]: r["risk_level"] for r in run.get("risk_matrix", [])}
113
+ critical_count = sum(
114
+ 1 for f in run.get("findings", []) if f.get("severity") == "critical"
115
+ )
116
+
117
+ result = {
118
+ "passed": passed,
119
+ "reason": reason,
120
+ "critical_count": critical_count,
121
+ "risk_matrix": risk_matrix,
122
+ }
123
+
124
+ if output_json:
125
+ output_json.write_text(json.dumps(result, indent=2), encoding="utf-8")
126
+
127
+ if passed:
128
+ console.print(f"[green]✓ GATE PASSED:[/green] {reason}")
129
+ else:
130
+ console.print(f"[red]✗ GATE FAILED:[/red] {reason}")
131
+ raise typer.Exit(1)
132
+
133
+
134
+ @app.command()
135
+ def diff(
136
+ run_a: Path = typer.Argument(..., help="Older audit run JSON"),
137
+ run_b: Path = typer.Argument(..., help="Newer audit run JSON"),
138
+ output_json: Optional[Path] = typer.Option(None, help="Write diff to JSON file"),
139
+ ) -> None:
140
+ """Compare two audit runs and show what changed."""
141
+ for p in [run_a, run_b]:
142
+ if not p.exists():
143
+ console.print(f"[red]Error:[/red] {p} not found")
144
+ raise typer.Exit(1)
145
+
146
+ result = diff_runs(run_a, run_b)
147
+
148
+ if output_json:
149
+ output_json.write_text(json.dumps(result, indent=2), encoding="utf-8")
150
+
151
+ console.print(render_diff_text(result))
152
+
153
+
154
+ @app.command()
155
+ def history(
156
+ directory: Path = typer.Option(Path(".rai-audit/history"), help="History directory"),
157
+ ) -> None:
158
+ """List past audit runs."""
159
+ runs = list_runs(directory)
160
+ if not runs:
161
+ console.print("No audit runs found.")
162
+ return
163
+
164
+ table = Table(title="Audit History")
165
+ table.add_column("File", style="cyan")
166
+ table.add_column("Project")
167
+ table.add_column("Risk")
168
+ table.add_column("Findings", justify="right")
169
+
170
+ for run_path in runs:
171
+ try:
172
+ run = load_run(run_path)
173
+ risk_levels = [r["risk_level"] for r in run.get("risk_matrix", [])]
174
+ worst = max(risk_levels, key=lambda r: ["low","medium","high","critical"].index(r)) if risk_levels else "n/a"
175
+ count = sum(1 for f in run.get("findings", []) if f.get("severity") not in ("passed", "info"))
176
+ table.add_row(run_path.name, run.get("project_name", "?"), worst.upper(), str(count))
177
+ except Exception:
178
+ table.add_row(run_path.name, "?", "?", "?")
179
+
180
+ console.print(table)
181
+
182
+
183
+ def _dict_to_report(d: dict):
184
+ """Reconstruct an AuditReport from a saved dict (for re-rendering)."""
185
+ from rai_audit.core.findings import (
186
+ AuditFinding, AuditReport, CategoryRisk,
187
+ RemediationEffort, RiskLevel, Severity,
188
+ )
189
+
190
+ findings = [
191
+ AuditFinding(
192
+ check_id=f["check_id"],
193
+ title=f["title"],
194
+ severity=Severity(f["severity"]),
195
+ description=f["description"],
196
+ evidence=f.get("evidence", {}),
197
+ recommendation=f.get("recommendation", ""),
198
+ category=f.get("category", ""),
199
+ affected_group=f.get("affected_group"),
200
+ remediation_effort=RemediationEffort(f.get("remediation_effort", "medium")),
201
+ standards_refs=f.get("standards_refs", []),
202
+ timestamp=f.get("timestamp"),
203
+ )
204
+ for f in d.get("findings", [])
205
+ ]
206
+
207
+ risk_matrix = [
208
+ CategoryRisk(
209
+ category=r["category"],
210
+ risk_level=RiskLevel(r["risk_level"]),
211
+ finding_count=r["finding_count"],
212
+ passed_count=r["passed_count"],
213
+ )
214
+ for r in d.get("risk_matrix", [])
215
+ ]
216
+
217
+ return AuditReport(
218
+ project_name=d.get("project_name", "Audit"),
219
+ audit_type=d.get("audit_type", ""),
220
+ risk_matrix=risk_matrix,
221
+ findings=findings,
222
+ metadata=d.get("metadata", {}),
223
+ overall_score=d.get("overall_score"),
224
+ )
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from abc import ABC, abstractmethod
5
+ from concurrent.futures import ThreadPoolExecutor
6
+
7
+ from rai_audit.core.findings import AuditReport
8
+
9
+
10
+ class BaseAudit(ABC):
11
+ """Abstract base class for all audit modules."""
12
+
13
+ @abstractmethod
14
+ def run(self) -> AuditReport:
15
+ """Execute the audit synchronously and return a report."""
16
+ ...
17
+
18
+ async def run_async(self) -> AuditReport:
19
+ """Execute the audit asynchronously (default: runs sync in a thread pool)."""
20
+ loop = asyncio.get_event_loop()
21
+ with ThreadPoolExecutor() as pool:
22
+ return await loop.run_in_executor(pool, self.run)
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class Severity(str, Enum):
11
+ CRITICAL = "critical"
12
+ HIGH = "high"
13
+ MEDIUM = "medium"
14
+ LOW = "low"
15
+ INFO = "info"
16
+ PASSED = "passed"
17
+
18
+
19
+ class RiskLevel(str, Enum):
20
+ CRITICAL = "critical"
21
+ HIGH = "high"
22
+ MEDIUM = "medium"
23
+ LOW = "low"
24
+
25
+
26
+ class RemediationEffort(str, Enum):
27
+ LOW = "low"
28
+ MEDIUM = "medium"
29
+ HIGH = "high"
30
+
31
+
32
+ @dataclass
33
+ class AuditFinding:
34
+ check_id: str
35
+ title: str
36
+ severity: Severity
37
+ description: str
38
+ evidence: dict[str, Any]
39
+ recommendation: str
40
+ category: str = ""
41
+ affected_group: str | None = None
42
+ remediation_effort: RemediationEffort = RemediationEffort.MEDIUM
43
+ standards_refs: list[str] = field(default_factory=list)
44
+ timestamp: str | None = None
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "check_id": self.check_id,
49
+ "title": self.title,
50
+ "severity": self.severity.value,
51
+ "description": self.description,
52
+ "evidence": self.evidence,
53
+ "recommendation": self.recommendation,
54
+ "category": self.category,
55
+ "affected_group": self.affected_group,
56
+ "remediation_effort": self.remediation_effort.value,
57
+ "standards_refs": self.standards_refs,
58
+ "timestamp": self.timestamp,
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class CategoryRisk:
64
+ category: str
65
+ risk_level: RiskLevel
66
+ finding_count: int
67
+ passed_count: int
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "category": self.category,
72
+ "risk_level": self.risk_level.value,
73
+ "finding_count": self.finding_count,
74
+ "passed_count": self.passed_count,
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class AuditReport:
80
+ project_name: str
81
+ audit_type: str
82
+ risk_matrix: list[CategoryRisk]
83
+ findings: list[AuditFinding]
84
+ metadata: dict[str, Any]
85
+ overall_score: float | None = None
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "project_name": self.project_name,
90
+ "audit_type": self.audit_type,
91
+ "risk_matrix": [r.to_dict() for r in self.risk_matrix],
92
+ "findings": [f.to_dict() for f in self.findings],
93
+ "metadata": self.metadata,
94
+ "overall_score": self.overall_score,
95
+ }
96
+
97
+ def to_json(self, path: str) -> None:
98
+ Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
99
+
100
+ def to_markdown(self, path: str) -> None:
101
+ from rai_audit.core.report import render_markdown
102
+
103
+ Path(path).write_text(render_markdown(self), encoding="utf-8")
104
+
105
+ def to_html(self, path: str) -> None:
106
+ from rai_audit.core.report import render_html
107
+
108
+ Path(path).write_text(render_html(self), encoding="utf-8")
109
+
110
+ @property
111
+ def critical_findings(self) -> list[AuditFinding]:
112
+ return [f for f in self.findings if f.severity == Severity.CRITICAL]
113
+
114
+ @property
115
+ def high_findings(self) -> list[AuditFinding]:
116
+ return [f for f in self.findings if f.severity == Severity.HIGH]
117
+
118
+ @property
119
+ def passed_findings(self) -> list[AuditFinding]:
120
+ return [f for f in self.findings if f.severity == Severity.PASSED]
121
+
122
+ @property
123
+ def overall_risk_level(self) -> RiskLevel:
124
+ for cat in self.risk_matrix:
125
+ if cat.risk_level == RiskLevel.CRITICAL:
126
+ return RiskLevel.CRITICAL
127
+ for cat in self.risk_matrix:
128
+ if cat.risk_level == RiskLevel.HIGH:
129
+ return RiskLevel.HIGH
130
+ for cat in self.risk_matrix:
131
+ if cat.risk_level == RiskLevel.MEDIUM:
132
+ return RiskLevel.MEDIUM
133
+ return RiskLevel.LOW
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ DEFAULT_HISTORY_DIR = Path(".rai-audit") / "history"
8
+
9
+
10
+ def save_run(report_dict: dict, directory: Path | None = None) -> Path:
11
+ """Persist an audit run as a timestamped JSON file."""
12
+ directory = directory or DEFAULT_HISTORY_DIR
13
+ directory.mkdir(parents=True, exist_ok=True)
14
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
15
+ slug = report_dict.get("project_name", "audit").replace(" ", "_").lower()
16
+ path = directory / f"{slug}_{ts}.json"
17
+ path.write_text(json.dumps(report_dict, indent=2), encoding="utf-8")
18
+ return path
19
+
20
+
21
+ def load_run(path: Path) -> dict:
22
+ """Load a persisted audit run."""
23
+ return json.loads(Path(path).read_text(encoding="utf-8"))
24
+
25
+
26
+ def list_runs(directory: Path | None = None) -> list[Path]:
27
+ """List all persisted audit run files, newest first."""
28
+ directory = directory or DEFAULT_HISTORY_DIR
29
+ if not directory.exists():
30
+ return []
31
+ return sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
32
+
33
+
34
+ def diff_runs(path_a: Path, path_b: Path) -> dict:
35
+ """
36
+ Compare two audit runs and return a structured diff.
37
+ path_a is the older run, path_b is the newer run.
38
+ """
39
+ a = load_run(Path(path_a))
40
+ b = load_run(Path(path_b))
41
+
42
+ risk_a = {r["category"]: r["risk_level"] for r in a.get("risk_matrix", [])}
43
+ risk_b = {r["category"]: r["risk_level"] for r in b.get("risk_matrix", [])}
44
+
45
+ risk_order = {"critical": 3, "high": 2, "medium": 1, "low": 0}
46
+ risk_changes: list[dict] = []
47
+ all_cats = sorted(set(risk_a) | set(risk_b))
48
+ for cat in all_cats:
49
+ old = risk_a.get(cat, "n/a")
50
+ new = risk_b.get(cat, "n/a")
51
+ if old != new:
52
+ old_rank = risk_order.get(old, -1)
53
+ new_rank = risk_order.get(new, -1)
54
+ direction = "REGRESSION" if new_rank > old_rank else "IMPROVED"
55
+ risk_changes.append(
56
+ {"category": cat, "from": old, "to": new, "direction": direction}
57
+ )
58
+
59
+ ids_a = {f["check_id"] for f in a.get("findings", []) if f.get("severity") != "passed"}
60
+ ids_b = {f["check_id"] for f in b.get("findings", []) if f.get("severity") != "passed"}
61
+
62
+ new_findings = [
63
+ f for f in b.get("findings", [])
64
+ if f["check_id"] not in ids_a and f.get("severity") != "passed"
65
+ ]
66
+ resolved_findings = [
67
+ f for f in a.get("findings", [])
68
+ if f["check_id"] not in ids_b and f.get("severity") != "passed"
69
+ ]
70
+
71
+ return {
72
+ "project_name": b.get("project_name", "unknown"),
73
+ "run_a": str(path_a),
74
+ "run_b": str(path_b),
75
+ "risk_changes": risk_changes,
76
+ "new_findings": new_findings,
77
+ "resolved_findings": resolved_findings,
78
+ "summary": {
79
+ "regressions": sum(1 for c in risk_changes if c["direction"] == "REGRESSION"),
80
+ "improvements": sum(1 for c in risk_changes if c["direction"] == "IMPROVED"),
81
+ "new_finding_count": len(new_findings),
82
+ "resolved_finding_count": len(resolved_findings),
83
+ },
84
+ }
85
+
86
+
87
+ def render_diff_text(diff: dict) -> str:
88
+ """Render a diff dict as a human-readable text report."""
89
+ lines: list[str] = []
90
+ lines.append(f"Audit Diff: {diff['project_name']}")
91
+ lines.append("─" * 60)
92
+
93
+ if diff["risk_changes"]:
94
+ for c in diff["risk_changes"]:
95
+ arrow = "⚠ REGRESSION" if c["direction"] == "REGRESSION" else "✓ IMPROVED"
96
+ lines.append(f"{c['category']:30s} {c['from'].upper()} → {c['to'].upper()} {arrow}")
97
+ else:
98
+ lines.append("No risk level changes.")
99
+
100
+ if diff["new_findings"]:
101
+ lines.append("\nNew findings:")
102
+ for f in diff["new_findings"]:
103
+ lines.append(f" [{f['severity'].upper()}] {f['check_id']}: {f['title']}")
104
+
105
+ if diff["resolved_findings"]:
106
+ lines.append("\nResolved findings:")
107
+ for f in diff["resolved_findings"]:
108
+ lines.append(f" ✓ {f['check_id']}: {f['title']}")
109
+
110
+ s = diff["summary"]
111
+ lines.append(
112
+ f"\nSummary: {s['regressions']} regression(s), {s['improvements']} improvement(s), "
113
+ f"{s['new_finding_count']} new, {s['resolved_finding_count']} resolved."
114
+ )
115
+ return "\n".join(lines)