devtrust-apr 0.2.0__py3-none-any.whl

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.
apr/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Agent-PR Reviewer (`apr`) - the Wave 2 lead bet of the DevTrust platform.
2
+
3
+ Consolidates the patterns from three existing GitHub Apps into one
4
+ deterministic, fast, AI-pattern-aware PR reviewer:
5
+
6
+ - ai-quality-gate -> AI-likelihood + verbose-pattern detection
7
+ - pr-coach -> coaching feedback (description quality, TODOs)
8
+ - commit-craft -> commit-message review and normalization
9
+
10
+ v0.0.1 ships the deterministic rule layer; LLM-backed review is layered
11
+ on top in v0.1+ once the rule output is stable enough to grade against.
12
+ """
13
+
14
+ __version__ = "0.2.0"
apr/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enables `python -m apr ...`."""
2
+
3
+ from apr.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
apr/cli.py ADDED
@@ -0,0 +1,150 @@
1
+ """Agent-PR Reviewer command-line interface.
2
+
3
+ apr version
4
+ apr review [--repo PATH] [--changed FILE ...] [--title S] [--description S]
5
+ [--diff PATH] [--enable-ai] [--ai-provider null|anthropic]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from apr import __version__
19
+ from apr.engine import review as review_engine
20
+ from apr.llm import LLMProvider, build_provider
21
+ from apr.output import write_json, write_markdown
22
+
23
+ app = typer.Typer(
24
+ name="apr",
25
+ help="Agent-PR Reviewer - deterministic + AI-pattern review for PRs.",
26
+ no_args_is_help=True,
27
+ add_completion=False,
28
+ )
29
+
30
+ console = Console()
31
+
32
+
33
+ @app.command()
34
+ def version() -> None:
35
+ """Print the installed Agent-PR Reviewer version."""
36
+ console.print(f"apr [bold]v{__version__}[/bold]")
37
+
38
+
39
+ @app.command()
40
+ def review(
41
+ repo: Annotated[
42
+ Path,
43
+ typer.Option("--repo", "-r", help="Repo to review. Defaults to current directory."),
44
+ ] = Path("."),
45
+ changed: Annotated[
46
+ list[str] | None,
47
+ typer.Option(
48
+ "--changed",
49
+ "-c",
50
+ help="Path(s) that changed. Repeat for multiple files.",
51
+ ),
52
+ ] = None,
53
+ title: Annotated[
54
+ str | None,
55
+ typer.Option("--title", "-t", help="The PR title (for metadata checks)."),
56
+ ] = None,
57
+ description: Annotated[
58
+ str | None,
59
+ typer.Option("--description", "-d", help="The PR description / body."),
60
+ ] = None,
61
+ diff_path: Annotated[
62
+ Path | None,
63
+ typer.Option(
64
+ "--diff",
65
+ help=(
66
+ "Path to a unified-diff file. Required for the "
67
+ "ai-review:diff-comprehension rule when --enable-ai."
68
+ ),
69
+ ),
70
+ ] = None,
71
+ enable_ai: Annotated[
72
+ bool,
73
+ typer.Option(
74
+ "--enable-ai/--no-enable-ai",
75
+ help=(
76
+ "Run the ai-review:* rule pack. Off by default. "
77
+ "ai-review:hallucinated-symbol needs a "
78
+ ".repox/architecture.json (run `repox build .` first)."
79
+ ),
80
+ ),
81
+ ] = False,
82
+ ai_provider: Annotated[
83
+ str,
84
+ typer.Option(
85
+ "--ai-provider",
86
+ help="LLM backend: 'null' (default, no calls) or 'anthropic'.",
87
+ ),
88
+ ] = "null",
89
+ quiet: Annotated[
90
+ bool,
91
+ typer.Option("--quiet", "-q", help="Suppress non-essential output."),
92
+ ] = False,
93
+ ) -> None:
94
+ """Run the review and emit `.apr/review.{json,md}`."""
95
+ if not repo.exists() or not repo.is_dir():
96
+ console.print(f"[red]Error:[/red] not a directory: {repo}")
97
+ raise typer.Exit(code=2)
98
+
99
+ repo = repo.resolve()
100
+ files = list(changed or [])
101
+
102
+ diff_text: str | None = None
103
+ if diff_path is not None:
104
+ try:
105
+ diff_text = diff_path.read_text(encoding="utf-8", errors="replace")
106
+ except OSError as exc:
107
+ console.print(f"[red]Error:[/red] cannot read diff: {exc}")
108
+ raise typer.Exit(code=2) from exc
109
+
110
+ provider: LLMProvider | None = None
111
+ if enable_ai:
112
+ # Anthropic API key from the conventional env var.
113
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
114
+ provider = build_provider(ai_provider, api_key)
115
+
116
+ if not quiet:
117
+ console.print(f"[bold]Reviewing[/bold] {repo}")
118
+ console.print(f"[dim]Changed files:[/dim] {len(files)}")
119
+ if enable_ai:
120
+ console.print(f"[dim]AI rules:[/dim] enabled (provider: {ai_provider})")
121
+
122
+ report = review_engine(
123
+ repo,
124
+ files,
125
+ pr_title=title,
126
+ pr_description=description,
127
+ enable_ai=enable_ai,
128
+ llm_provider=provider,
129
+ diff=diff_text,
130
+ )
131
+ json_path = write_json(report, repo)
132
+ md_path = write_markdown(report, repo)
133
+
134
+ if quiet:
135
+ return
136
+
137
+ s = report.stats
138
+ table = Table(title="\nReview summary", show_header=True, header_style="bold")
139
+ table.add_column("Severity", style="cyan")
140
+ table.add_column("Count", justify="right")
141
+ table.add_row("info", str(s.info))
142
+ table.add_row("warning", str(s.warning))
143
+ table.add_row("error", str(s.error))
144
+ table.add_row("critical", str(s.critical))
145
+ table.add_row("[bold]total[/bold]", f"[bold]{s.total}[/bold]")
146
+ console.print(table)
147
+ if s.blocking > 0:
148
+ console.print(f"[red]Blocking findings:[/red] {s.blocking} (error + critical)")
149
+ console.print(f"\n[green]✓[/green] wrote [bold]{json_path}[/bold]")
150
+ console.print(f"[green]✓[/green] wrote [bold]{md_path}[/bold]")
apr/engine.py ADDED
@@ -0,0 +1,114 @@
1
+ """Top-level review orchestration.
2
+
3
+ The engine takes a repo root, a list of changed files, optional PR
4
+ metadata (title + description), and optional AI configuration (provider
5
+ + diff). It runs:
6
+
7
+ 1. PR-level checks (title, description) -- once per review.
8
+ 2. File-level checks for each changed file in a known language.
9
+ 3. AI rule pack (apr.rules_ai), gated behind `enable_ai=True`.
10
+
11
+ Findings are deduplicated and stable-sorted (file, line, severity).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from apr import __version__
21
+ from apr.llm import LLMProvider, NullProvider
22
+ from apr.models import (
23
+ Finding,
24
+ ReviewInputs,
25
+ ReviewReport,
26
+ ReviewStats,
27
+ Severity,
28
+ )
29
+ from apr.repox_integration import load as load_repox_artifact
30
+ from apr.rules import check_file, check_pr_metadata
31
+ from apr.rules_ai import run_ai_rules
32
+
33
+ _SEVERITY_ORDER: dict[Severity, int] = {
34
+ "info": 0,
35
+ "warning": 1,
36
+ "error": 2,
37
+ "critical": 3,
38
+ }
39
+
40
+
41
+ def _stable_key(f: Finding) -> tuple[str, int, int, str]:
42
+ """Stable sort key: file, line, severity rank (asc), rule_id."""
43
+ return (
44
+ f.file or "",
45
+ f.line or 0,
46
+ _SEVERITY_ORDER[f.severity],
47
+ f.rule_id,
48
+ )
49
+
50
+
51
+ def review(
52
+ repo_root: Path,
53
+ changed_files: list[str],
54
+ pr_title: str | None = None,
55
+ pr_description: str | None = None,
56
+ *,
57
+ enable_ai: bool = False,
58
+ llm_provider: LLMProvider | None = None,
59
+ diff: str | None = None,
60
+ ) -> ReviewReport:
61
+ """Run all checks and produce a ReviewReport.
62
+
63
+ AI rules are off by default. Pass `enable_ai=True` AND optionally
64
+ a `llm_provider` (defaults to NullProvider) to enable
65
+ `ai-review:hallucinated-symbol` (deterministic, uses repox call
66
+ graph) and `ai-review:diff-comprehension` (delegates to the
67
+ provider).
68
+ """
69
+ findings: list[Finding] = []
70
+
71
+ findings.extend(check_pr_metadata(pr_title, pr_description))
72
+ for rel in changed_files:
73
+ findings.extend(check_file(repo_root, rel))
74
+
75
+ if enable_ai:
76
+ artifact = load_repox_artifact(repo_root)
77
+ provider: LLMProvider = llm_provider or NullProvider()
78
+ findings.extend(
79
+ run_ai_rules(
80
+ repo_root,
81
+ changed_files,
82
+ artifact=artifact,
83
+ provider=provider,
84
+ diff=diff,
85
+ pr_title=pr_title,
86
+ pr_description=pr_description,
87
+ )
88
+ )
89
+
90
+ findings.sort(key=_stable_key)
91
+
92
+ counts: Counter[Severity] = Counter()
93
+ for f in findings:
94
+ counts[f.severity] += 1
95
+
96
+ stats = ReviewStats(
97
+ info=counts["info"],
98
+ warning=counts["warning"],
99
+ error=counts["error"],
100
+ critical=counts["critical"],
101
+ )
102
+
103
+ return ReviewReport(
104
+ generated_at=datetime.now(UTC),
105
+ tool_version=__version__,
106
+ inputs=ReviewInputs(
107
+ repo_root=str(repo_root),
108
+ changed_files=sorted(changed_files),
109
+ pr_title=pr_title,
110
+ pr_description=pr_description,
111
+ ),
112
+ stats=stats,
113
+ findings=findings,
114
+ )
apr/llm.py ADDED
@@ -0,0 +1,188 @@
1
+ """LLM provider interface for AI-backed apr rules.
2
+
3
+ - `LLMProvider` Protocol every backend implements
4
+ - `NullProvider` returns no findings; used when AI is disabled
5
+ - `AnthropicProvider` Claude-backed provider (real, v0.1.1+)
6
+
7
+ The rule modules talk to this interface, never to a vendor SDK directly.
8
+ Swapping providers (Anthropic / OpenAI / Bedrock / a local model) is a
9
+ matter of writing a new `LLMProvider` implementation -- not touching the
10
+ rule code or the engine.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from typing import Any, Protocol
18
+
19
+ from apr.models import Finding
20
+ from apr.prompts import (
21
+ DEFAULT_MAX_DIFF_CHARS,
22
+ DEFAULT_MAX_TOKENS,
23
+ SYSTEM_PROMPT,
24
+ build_prompt,
25
+ parse_response,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class LLMProvider(Protocol):
32
+ """Anything that can answer 'does this PR description match the diff'."""
33
+
34
+ name: str
35
+
36
+ def analyze_diff(
37
+ self,
38
+ diff: str,
39
+ pr_title: str | None,
40
+ pr_description: str | None,
41
+ ) -> list[Finding]:
42
+ """Return findings (possibly empty) about diff/description coherence.
43
+
44
+ Implementations MUST be idempotent and side-effect-free other than
45
+ outbound HTTP. They MUST handle their own timeouts and rate limits;
46
+ the engine treats any exception as 'no findings' and continues.
47
+ """
48
+ ...
49
+
50
+
51
+ class NullProvider:
52
+ """No-op provider. Returned when AI is disabled or no key configured."""
53
+
54
+ name = "null"
55
+
56
+ def analyze_diff(
57
+ self,
58
+ diff: str,
59
+ pr_title: str | None,
60
+ pr_description: str | None,
61
+ ) -> list[Finding]:
62
+ return []
63
+
64
+
65
+ class AnthropicProvider:
66
+ """Anthropic Claude as the LLM backend.
67
+
68
+ Uses the official `anthropic` Python SDK (Messages API, non-streaming).
69
+ The provider builds a JSON-shaped prompt via `apr.prompts.build_prompt`,
70
+ parses the reply via `apr.prompts.parse_response`, and returns
71
+ `Finding` rows that the engine re-namespaces under
72
+ `ai-review:diff-comprehension`.
73
+
74
+ Cost / safety:
75
+ - Diff is truncated to `max_diff_chars` (default 60,000) before
76
+ sending. A typical PR fits comfortably; runaway lockfile diffs
77
+ are bounded.
78
+ - `max_tokens` on the reply is capped (default 1024). Plenty for
79
+ a JSON list of findings; not enough for the model to spiral.
80
+ - On any SDK exception (auth, rate limit, network), we log and
81
+ return [] rather than letting the engine die.
82
+ """
83
+
84
+ name = "anthropic"
85
+
86
+ def __init__(
87
+ self,
88
+ api_key: str,
89
+ model: str = "claude-sonnet-4-6",
90
+ max_tokens: int = DEFAULT_MAX_TOKENS,
91
+ max_diff_chars: int = DEFAULT_MAX_DIFF_CHARS,
92
+ client: Any | None = None,
93
+ ) -> None:
94
+ try:
95
+ import anthropic # noqa: F401 -- intentional probe
96
+ except ImportError as exc:
97
+ raise RuntimeError(
98
+ "AnthropicProvider requires the `anthropic` package. "
99
+ "Install with `pip install apr[ai]` or "
100
+ "`uv add anthropic` in this workspace."
101
+ ) from exc
102
+ self.api_key = api_key
103
+ self.model = model
104
+ self.max_tokens = max_tokens
105
+ self.max_diff_chars = max_diff_chars
106
+ # Tests inject `client`. Production builds a fresh client per provider.
107
+ self._client = client
108
+
109
+ def _ensure_client(self) -> Any:
110
+ if self._client is not None:
111
+ return self._client
112
+ from anthropic import Anthropic
113
+
114
+ self._client = Anthropic(api_key=self.api_key)
115
+ return self._client
116
+
117
+ def analyze_diff(
118
+ self,
119
+ diff: str,
120
+ pr_title: str | None,
121
+ pr_description: str | None,
122
+ ) -> list[Finding]:
123
+ prompt = build_prompt(
124
+ diff=diff,
125
+ pr_title=pr_title,
126
+ pr_description=pr_description,
127
+ max_diff_chars=self.max_diff_chars,
128
+ )
129
+ try:
130
+ client = self._ensure_client()
131
+ resp = client.messages.create(
132
+ model=self.model,
133
+ max_tokens=self.max_tokens,
134
+ system=SYSTEM_PROMPT,
135
+ messages=[{"role": "user", "content": prompt}],
136
+ )
137
+ except Exception as exc:
138
+ logger.warning("AnthropicProvider request failed: %s", exc)
139
+ return []
140
+
141
+ text = _extract_text(resp)
142
+ if text is None:
143
+ return []
144
+ return parse_response(text)
145
+
146
+
147
+ def _extract_text(resp: Any) -> str | None:
148
+ """Pull the text body out of an `anthropic.Message` response.
149
+
150
+ The SDK exposes `.content` as a list of content blocks; the simplest
151
+ case is one `text` block. We concatenate all text-shaped blocks
152
+ together for robustness against future models that prepend a
153
+ 'thinking' segment or similar.
154
+ """
155
+ content = getattr(resp, "content", None)
156
+ if content is None:
157
+ return None
158
+ pieces: list[str] = []
159
+ for block in content:
160
+ block_type = getattr(block, "type", None)
161
+ if block_type == "text":
162
+ text = getattr(block, "text", None)
163
+ if isinstance(text, str):
164
+ pieces.append(text)
165
+ if not pieces:
166
+ return None
167
+ return "\n".join(pieces)
168
+
169
+
170
+ def build_provider(name: str | None, api_key: str | None) -> LLMProvider:
171
+ """Map a provider name to a concrete provider instance.
172
+
173
+ Returns NullProvider when the input doesn't name a real provider or
174
+ when credentials are missing -- the engine never raises because the
175
+ operator forgot to set an env var.
176
+ """
177
+ if not name or name == "null":
178
+ return NullProvider()
179
+ if name == "anthropic":
180
+ # Allow a per-process model override via env var.
181
+ model = os.environ.get("APR_ANTHROPIC_MODEL", "claude-sonnet-4-6")
182
+ if not api_key:
183
+ return NullProvider()
184
+ try:
185
+ return AnthropicProvider(api_key=api_key, model=model)
186
+ except RuntimeError:
187
+ return NullProvider()
188
+ return NullProvider()
apr/models.py ADDED
@@ -0,0 +1,111 @@
1
+ """Pydantic models for the Agent-PR Reviewer.
2
+
3
+ The schema in this file is the public API of `apr`. Downstream consumers
4
+ (the GitHub App that posts comments, dashboards, anyone reading
5
+ `.apr/review.json`) read this shape. Treat changes as breaking.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ SCHEMA_VERSION = "0.0.1"
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Findings
20
+ # ---------------------------------------------------------------------------
21
+
22
+ Severity = Literal["info", "warning", "error", "critical"]
23
+
24
+ Category = Literal[
25
+ "quality", # general code-quality smells
26
+ "security", # potentially unsafe patterns
27
+ "style", # convention / readability nits
28
+ "ai-pattern", # signs of AI-generated boilerplate or hallucinations
29
+ "todo", # TODO / FIXME / XXX bookkeeping
30
+ "commit", # commit-message issues (when `apr` is fed commit data)
31
+ ]
32
+
33
+
34
+ class Finding(BaseModel):
35
+ """One reviewer finding tied to a file and (optionally) a line."""
36
+
37
+ model_config = ConfigDict(frozen=True)
38
+
39
+ rule_id: str = Field(
40
+ ...,
41
+ description=(
42
+ "Stable identifier for the rule that produced this finding "
43
+ "(e.g. 'bare-except', 'todo-no-ticket', 'pr-description-too-short')."
44
+ ),
45
+ )
46
+ severity: Severity
47
+ category: Category
48
+ message: str
49
+ file: str | None = Field(
50
+ default=None,
51
+ description="Repo-relative POSIX path. None for repo-level findings.",
52
+ )
53
+ line: int | None = Field(default=None, ge=1)
54
+ suggestion: str | None = Field(
55
+ default=None,
56
+ description="Optional human-readable suggested fix.",
57
+ )
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Review report
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class ReviewInputs(BaseModel):
66
+ """The inputs the reviewer saw -- captured for reproducibility."""
67
+
68
+ model_config = ConfigDict(frozen=True)
69
+
70
+ repo_root: str
71
+ changed_files: list[str] = Field(default_factory=list)
72
+ pr_title: str | None = None
73
+ pr_description: str | None = None
74
+
75
+
76
+ class ReviewStats(BaseModel):
77
+ """Aggregate counts useful for terminal display + CI gates."""
78
+
79
+ model_config = ConfigDict(frozen=True)
80
+
81
+ info: int = Field(..., ge=0)
82
+ warning: int = Field(..., ge=0)
83
+ error: int = Field(..., ge=0)
84
+ critical: int = Field(..., ge=0)
85
+
86
+ @property
87
+ def total(self) -> int:
88
+ return self.info + self.warning + self.error + self.critical
89
+
90
+ @property
91
+ def blocking(self) -> int:
92
+ """Findings severe enough that a CI gate should stop the merge."""
93
+ return self.error + self.critical
94
+
95
+
96
+ class ReviewReport(BaseModel):
97
+ """The full report emitted by `apr review`.
98
+
99
+ JSON layout: `.apr/review.json`
100
+ Human companion: `.apr/review.md`
101
+ """
102
+
103
+ model_config = ConfigDict(frozen=False)
104
+
105
+ schema_version: str = SCHEMA_VERSION
106
+ generated_at: datetime
107
+ tool_version: str
108
+
109
+ inputs: ReviewInputs
110
+ stats: ReviewStats
111
+ findings: list[Finding] = Field(default_factory=list)
apr/output.py ADDED
@@ -0,0 +1,93 @@
1
+ """Writers for the review report.
2
+
3
+ Two outputs:
4
+ - review.json - canonical, machine-readable artifact (versioned)
5
+ - review.md - human-readable companion derived from the JSON
6
+
7
+ Both land in `.apr/` at the analyzed repo root.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from apr.models import ReviewReport
15
+
16
+ OUTPUT_DIR_NAME = ".apr"
17
+
18
+
19
+ def write_json(report: ReviewReport, root: Path) -> Path:
20
+ out_dir = root / OUTPUT_DIR_NAME
21
+ out_dir.mkdir(parents=True, exist_ok=True)
22
+ out_path = out_dir / "review.json"
23
+ out_path.write_text(
24
+ report.model_dump_json(indent=2, exclude_none=False),
25
+ encoding="utf-8",
26
+ )
27
+ return out_path
28
+
29
+
30
+ def write_markdown(report: ReviewReport, root: Path) -> Path:
31
+ out_dir = root / OUTPUT_DIR_NAME
32
+ out_dir.mkdir(parents=True, exist_ok=True)
33
+ out_path = out_dir / "review.md"
34
+
35
+ lines: list[str] = []
36
+ lines.append("# Code review")
37
+ lines.append("")
38
+ lines.append(
39
+ f"> Generated by **apr v{report.tool_version}** at "
40
+ f"{report.generated_at.isoformat(timespec='seconds')}"
41
+ )
42
+ lines.append(f"> Schema: `{report.schema_version}`")
43
+ lines.append("")
44
+ lines.append("---")
45
+ lines.append("")
46
+
47
+ s = report.stats
48
+ lines.append("## Summary")
49
+ lines.append("")
50
+ lines.append(
51
+ f"- **Total findings:** {s.total} "
52
+ f"(info: {s.info}, warning: {s.warning}, "
53
+ f"error: {s.error}, critical: {s.critical})"
54
+ )
55
+ lines.append(f"- **Blocking (error + critical):** {s.blocking}")
56
+ lines.append(f"- **Changed files reviewed:** {len(report.inputs.changed_files)}")
57
+ lines.append("")
58
+
59
+ if not report.findings:
60
+ lines.append("_No findings. Nice work._")
61
+ lines.append("")
62
+ else:
63
+ lines.append("## Findings")
64
+ lines.append("")
65
+ lines.append("| Severity | File | Line | Rule | Message |")
66
+ lines.append("|---|---|---:|---|---|")
67
+ for f in report.findings:
68
+ file_disp = f"`{f.file}`" if f.file else "_(repo-level)_"
69
+ line_disp = str(f.line) if f.line else ""
70
+ # Pipes inside messages would break the table - escape.
71
+ msg = f.message.replace("|", "\\|")
72
+ lines.append(f"| `{f.severity}` | {file_disp} | {line_disp} | `{f.rule_id}` | {msg} |")
73
+ lines.append("")
74
+
75
+ # Surface up to 10 suggestions in a separate block, since they're
76
+ # the most actionable thing for the author.
77
+ with_suggestions = [f for f in report.findings if f.suggestion]
78
+ if with_suggestions:
79
+ lines.append("### Suggested fixes")
80
+ lines.append("")
81
+ for f in with_suggestions[:10]:
82
+ where = (
83
+ f"`{f.file}:{f.line}`"
84
+ if f.file and f.line
85
+ else f"`{f.file}`"
86
+ if f.file
87
+ else "_(repo-level)_"
88
+ )
89
+ lines.append(f"- {where} ({f.rule_id}) — {f.suggestion}")
90
+ lines.append("")
91
+
92
+ out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
93
+ return out_path