flagrant 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.
- flagrant-0.1.0/.gitignore +5 -0
- flagrant-0.1.0/LICENSE +21 -0
- flagrant-0.1.0/PKG-INFO +121 -0
- flagrant-0.1.0/README.md +93 -0
- flagrant-0.1.0/flagrant/__init__.py +3 -0
- flagrant-0.1.0/flagrant/config.py +174 -0
- flagrant-0.1.0/flagrant/formatter.py +91 -0
- flagrant-0.1.0/flagrant/git_utils.py +151 -0
- flagrant-0.1.0/flagrant/main.py +306 -0
- flagrant-0.1.0/flagrant/prompt.py +83 -0
- flagrant-0.1.0/flagrant/reviewer.py +299 -0
- flagrant-0.1.0/pyproject.toml +41 -0
- flagrant-0.1.0/tests/__init__.py +1 -0
flagrant-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 parrwiz
|
|
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.
|
flagrant-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flagrant
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local AI code reviewer for pre-commit checks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/flagrant/flagrant
|
|
6
|
+
Project-URL: Repository, https://github.com/flagrant/flagrant
|
|
7
|
+
Author: Flagrant
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,cli,code-review,developer-tools,linting
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: anthropic>=0.40.0
|
|
21
|
+
Requires-Dist: gitpython>=3.1.0
|
|
22
|
+
Requires-Dist: google-genai>=1.0.0
|
|
23
|
+
Requires-Dist: openai>=1.0.0
|
|
24
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
|
+
Requires-Dist: rich>=13.0.0
|
|
26
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# flagrant
|
|
30
|
+
|
|
31
|
+
CLI tool that reviews your code before you commit. Uses LLMs to catch bugs, security issues, and bad patterns the same way a senior dev would in a PR review.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
$ flagrant --staged
|
|
35
|
+
|
|
36
|
+
───────────────────────────────────
|
|
37
|
+
flagrant | 3 issues flagged (1 high | 1 medium | 1 low)
|
|
38
|
+
───────────────────────────────────
|
|
39
|
+
|
|
40
|
+
● HIGH auth.py line 34
|
|
41
|
+
SQL query built with string concatenation
|
|
42
|
+
Fix: use parameterized queries
|
|
43
|
+
|
|
44
|
+
● MEDIUM utils.py line 12
|
|
45
|
+
Function has no error handling
|
|
46
|
+
Fix: wrap in try/except, handle edge cases
|
|
47
|
+
|
|
48
|
+
● LOW main.py line 5
|
|
49
|
+
Unused import os
|
|
50
|
+
Fix: remove it
|
|
51
|
+
───────────────────────────────────
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exits with code 1 on high-severity issues. Works as a pre-commit hook.
|
|
55
|
+
|
|
56
|
+
## install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install flagrant
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
flagrant . # review entire repo
|
|
66
|
+
flagrant --staged # only staged changes (fast, cheap)
|
|
67
|
+
flagrant --file app.py # single file
|
|
68
|
+
flagrant --strict # security-focused pass
|
|
69
|
+
flagrant --explain # explain why each issue matters
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
First run prompts for your API key. Supports **Claude**, **OpenAI**, **Gemini**, and **DeepSeek**.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
flagrant config # switch provider or update key
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## git hook
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
flagrant install-hook # auto-review on every commit
|
|
82
|
+
flagrant remove-hook # undo
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Blocks commits with high-severity issues. Skip with `git commit --no-verify`.
|
|
86
|
+
|
|
87
|
+
## project config
|
|
88
|
+
|
|
89
|
+
Drop a `.flagrant` file in your repo root:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"ignore": ["migrations/", "tests/", "vendor/"],
|
|
94
|
+
"strict": true,
|
|
95
|
+
"explain": false,
|
|
96
|
+
"language": "python"
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## how it works
|
|
101
|
+
|
|
102
|
+
1. Reads your files or git diff
|
|
103
|
+
2. Chunks large files to fit context windows
|
|
104
|
+
3. Sends to your configured LLM with a review-focused system prompt
|
|
105
|
+
4. Parses structured JSON issues from the response
|
|
106
|
+
5. Prints results, returns exit code 1 if anything is high severity
|
|
107
|
+
|
|
108
|
+
No telemetry. No accounts. Your code goes straight to whichever LLM provider you pick and nowhere else.
|
|
109
|
+
|
|
110
|
+
## providers
|
|
111
|
+
|
|
112
|
+
| Provider | Default model | Env var |
|
|
113
|
+
|----------|--------------|---------|
|
|
114
|
+
| Claude | claude-sonnet-4-20250514 | `ANTHROPIC_API_KEY` |
|
|
115
|
+
| OpenAI | gpt-4o | `OPENAI_API_KEY` |
|
|
116
|
+
| Gemini | gemini-2.5-flash | `GEMINI_API_KEY` |
|
|
117
|
+
| DeepSeek | deepseek-chat | `DEEPSEEK_API_KEY` |
|
|
118
|
+
|
|
119
|
+
## license
|
|
120
|
+
|
|
121
|
+
MIT
|
flagrant-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# flagrant
|
|
2
|
+
|
|
3
|
+
CLI tool that reviews your code before you commit. Uses LLMs to catch bugs, security issues, and bad patterns the same way a senior dev would in a PR review.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ flagrant --staged
|
|
7
|
+
|
|
8
|
+
───────────────────────────────────
|
|
9
|
+
flagrant | 3 issues flagged (1 high | 1 medium | 1 low)
|
|
10
|
+
───────────────────────────────────
|
|
11
|
+
|
|
12
|
+
● HIGH auth.py line 34
|
|
13
|
+
SQL query built with string concatenation
|
|
14
|
+
Fix: use parameterized queries
|
|
15
|
+
|
|
16
|
+
● MEDIUM utils.py line 12
|
|
17
|
+
Function has no error handling
|
|
18
|
+
Fix: wrap in try/except, handle edge cases
|
|
19
|
+
|
|
20
|
+
● LOW main.py line 5
|
|
21
|
+
Unused import os
|
|
22
|
+
Fix: remove it
|
|
23
|
+
───────────────────────────────────
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Exits with code 1 on high-severity issues. Works as a pre-commit hook.
|
|
27
|
+
|
|
28
|
+
## install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install flagrant
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
flagrant . # review entire repo
|
|
38
|
+
flagrant --staged # only staged changes (fast, cheap)
|
|
39
|
+
flagrant --file app.py # single file
|
|
40
|
+
flagrant --strict # security-focused pass
|
|
41
|
+
flagrant --explain # explain why each issue matters
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
First run prompts for your API key. Supports **Claude**, **OpenAI**, **Gemini**, and **DeepSeek**.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
flagrant config # switch provider or update key
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## git hook
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
flagrant install-hook # auto-review on every commit
|
|
54
|
+
flagrant remove-hook # undo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Blocks commits with high-severity issues. Skip with `git commit --no-verify`.
|
|
58
|
+
|
|
59
|
+
## project config
|
|
60
|
+
|
|
61
|
+
Drop a `.flagrant` file in your repo root:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"ignore": ["migrations/", "tests/", "vendor/"],
|
|
66
|
+
"strict": true,
|
|
67
|
+
"explain": false,
|
|
68
|
+
"language": "python"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## how it works
|
|
73
|
+
|
|
74
|
+
1. Reads your files or git diff
|
|
75
|
+
2. Chunks large files to fit context windows
|
|
76
|
+
3. Sends to your configured LLM with a review-focused system prompt
|
|
77
|
+
4. Parses structured JSON issues from the response
|
|
78
|
+
5. Prints results, returns exit code 1 if anything is high severity
|
|
79
|
+
|
|
80
|
+
No telemetry. No accounts. Your code goes straight to whichever LLM provider you pick and nowhere else.
|
|
81
|
+
|
|
82
|
+
## providers
|
|
83
|
+
|
|
84
|
+
| Provider | Default model | Env var |
|
|
85
|
+
|----------|--------------|---------|
|
|
86
|
+
| Claude | claude-sonnet-4-20250514 | `ANTHROPIC_API_KEY` |
|
|
87
|
+
| OpenAI | gpt-4o | `OPENAI_API_KEY` |
|
|
88
|
+
| Gemini | gemini-2.5-flash | `GEMINI_API_KEY` |
|
|
89
|
+
| DeepSeek | deepseek-chat | `DEEPSEEK_API_KEY` |
|
|
90
|
+
|
|
91
|
+
## license
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Project config and API key management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.prompt import Prompt, Confirm
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR = Path.home() / ".config" / "flagrant"
|
|
18
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
19
|
+
|
|
20
|
+
PROVIDERS = {
|
|
21
|
+
"claude": {
|
|
22
|
+
"name": "Claude (Anthropic)",
|
|
23
|
+
"env_var": "ANTHROPIC_API_KEY",
|
|
24
|
+
"models": ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022"],
|
|
25
|
+
},
|
|
26
|
+
"openai": {
|
|
27
|
+
"name": "OpenAI",
|
|
28
|
+
"env_var": "OPENAI_API_KEY",
|
|
29
|
+
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
30
|
+
},
|
|
31
|
+
"gemini": {
|
|
32
|
+
"name": "Gemini (Google)",
|
|
33
|
+
"env_var": "GEMINI_API_KEY",
|
|
34
|
+
"models": ["gemini-2.5-pro", "gemini-2.5-pro"],
|
|
35
|
+
},
|
|
36
|
+
"deepseek": {
|
|
37
|
+
"name": "DeepSeek",
|
|
38
|
+
"env_var": "DEEPSEEK_API_KEY",
|
|
39
|
+
"models": ["deepseek-chat", "deepseek-chat"],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ProjectConfig:
|
|
46
|
+
"""Per-project .flagrant config."""
|
|
47
|
+
ignore: list[str] = field(default_factory=list)
|
|
48
|
+
strict: bool = False
|
|
49
|
+
explain: bool = False
|
|
50
|
+
language: str = "auto"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class AppConfig:
|
|
55
|
+
"""Global app config (API key + provider)."""
|
|
56
|
+
provider: str = "claude"
|
|
57
|
+
api_key: str = ""
|
|
58
|
+
model: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def effective_model(self) -> str:
|
|
62
|
+
if self.model:
|
|
63
|
+
return self.model
|
|
64
|
+
return PROVIDERS[self.provider]["models"][0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_project_config(repo_root: Path) -> ProjectConfig:
|
|
68
|
+
"""Load .flagrant config from repo root, or return defaults."""
|
|
69
|
+
config_path = repo_root / ".flagrant"
|
|
70
|
+
if not config_path.exists():
|
|
71
|
+
return ProjectConfig()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(config_path) as f:
|
|
75
|
+
data = json.load(f)
|
|
76
|
+
return ProjectConfig(
|
|
77
|
+
ignore=data.get("ignore", []),
|
|
78
|
+
strict=data.get("strict", False),
|
|
79
|
+
explain=data.get("explain", False),
|
|
80
|
+
language=data.get("language", "auto"),
|
|
81
|
+
)
|
|
82
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
83
|
+
console.print(f"[yellow]warning: could not parse .flagrant config: {e}[/yellow]")
|
|
84
|
+
return ProjectConfig()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _save_app_config(config: AppConfig) -> None:
|
|
88
|
+
"""Write config to disk."""
|
|
89
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
data = {
|
|
91
|
+
"provider": config.provider,
|
|
92
|
+
"api_key": config.api_key,
|
|
93
|
+
"model": config.model,
|
|
94
|
+
}
|
|
95
|
+
with open(CONFIG_FILE, "w") as f:
|
|
96
|
+
json.dump(data, f, indent=2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_saved_config() -> Optional[AppConfig]:
|
|
100
|
+
"""Load config from disk, if it exists."""
|
|
101
|
+
if not CONFIG_FILE.exists():
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
with open(CONFIG_FILE) as f:
|
|
105
|
+
data = json.load(f)
|
|
106
|
+
return AppConfig(
|
|
107
|
+
provider=data.get("provider", "claude"),
|
|
108
|
+
api_key=data.get("api_key", ""),
|
|
109
|
+
model=data.get("model"),
|
|
110
|
+
)
|
|
111
|
+
except (json.JSONDecodeError, OSError):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_env_key() -> Optional[AppConfig]:
|
|
116
|
+
"""Check environment variables for API keys."""
|
|
117
|
+
load_dotenv()
|
|
118
|
+
for provider_id, info in PROVIDERS.items():
|
|
119
|
+
key = os.getenv(info["env_var"])
|
|
120
|
+
if key:
|
|
121
|
+
return AppConfig(provider=provider_id, api_key=key)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def setup_api_key_interactive() -> AppConfig:
|
|
126
|
+
"""Run the interactive API key setup prompt."""
|
|
127
|
+
console.print()
|
|
128
|
+
console.print("[bold cyan]flagrant setup[/bold cyan]")
|
|
129
|
+
console.print()
|
|
130
|
+
console.print("Choose your AI provider:")
|
|
131
|
+
console.print(" [bold]1[/bold] Claude (Anthropic)")
|
|
132
|
+
console.print(" [bold]2[/bold] OpenAI")
|
|
133
|
+
console.print(" [bold]3[/bold] Gemini (Google)")
|
|
134
|
+
console.print(" [bold]4[/bold] DeepSeek")
|
|
135
|
+
console.print()
|
|
136
|
+
|
|
137
|
+
choice = Prompt.ask("Provider", choices=["1", "2", "3", "4"], default="1")
|
|
138
|
+
provider_map = {"1": "claude", "2": "openai", "3": "gemini", "4": "deepseek"}
|
|
139
|
+
provider = provider_map[choice]
|
|
140
|
+
|
|
141
|
+
info = PROVIDERS[provider]
|
|
142
|
+
console.print()
|
|
143
|
+
api_key = Prompt.ask(f"Paste your {info['name']} API key")
|
|
144
|
+
|
|
145
|
+
if not api_key.strip():
|
|
146
|
+
console.print("[red]error: no API key provided[/red]")
|
|
147
|
+
raise SystemExit(1)
|
|
148
|
+
|
|
149
|
+
config = AppConfig(provider=provider, api_key=api_key.strip())
|
|
150
|
+
_save_app_config(config)
|
|
151
|
+
|
|
152
|
+
console.print(f"[green]saved. using {info['name']}[/green]")
|
|
153
|
+
console.print(f"[dim]Config stored at {CONFIG_FILE}[/dim]")
|
|
154
|
+
console.print()
|
|
155
|
+
return config
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_app_config(require_key: bool = True) -> AppConfig:
|
|
159
|
+
"""Resolve API config. Checks env, then saved config, then prompts."""
|
|
160
|
+
# 1. Environment variable
|
|
161
|
+
env_config = _check_env_key()
|
|
162
|
+
if env_config and env_config.api_key:
|
|
163
|
+
return env_config
|
|
164
|
+
|
|
165
|
+
# 2. Saved config
|
|
166
|
+
saved = _load_saved_config()
|
|
167
|
+
if saved and saved.api_key:
|
|
168
|
+
return saved
|
|
169
|
+
|
|
170
|
+
# 3. Interactive setup
|
|
171
|
+
if not require_key:
|
|
172
|
+
return AppConfig()
|
|
173
|
+
|
|
174
|
+
return setup_api_key_interactive()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Terminal output formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from flagrant.reviewer import Issue
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
SEVERITY_STYLES = {
|
|
14
|
+
"high": ("bold red", "●", "HIGH "),
|
|
15
|
+
"medium": ("bold yellow", "●", "MEDIUM"),
|
|
16
|
+
"low": ("bold blue", "●", "LOW "),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def display_issues(issues: list[Issue], explain: bool = False) -> None:
|
|
21
|
+
"""Print review issues to the terminal."""
|
|
22
|
+
if not issues:
|
|
23
|
+
console.print()
|
|
24
|
+
console.print("[bold green]no issues found[/bold green]")
|
|
25
|
+
console.print()
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# Sort: high first, then medium, then low
|
|
29
|
+
severity_order = {"high": 0, "medium": 1, "low": 2}
|
|
30
|
+
issues.sort(key=lambda i: severity_order.get(i.severity, 3))
|
|
31
|
+
|
|
32
|
+
# Count by severity
|
|
33
|
+
counts = {}
|
|
34
|
+
for issue in issues:
|
|
35
|
+
counts[issue.severity] = counts.get(issue.severity, 0) + 1
|
|
36
|
+
|
|
37
|
+
# Header
|
|
38
|
+
total = len(issues)
|
|
39
|
+
header_parts = []
|
|
40
|
+
if counts.get("high"):
|
|
41
|
+
header_parts.append(f"[bold red]{counts['high']} high[/bold red]")
|
|
42
|
+
if counts.get("medium"):
|
|
43
|
+
header_parts.append(f"[bold yellow]{counts['medium']} medium[/bold yellow]")
|
|
44
|
+
if counts.get("low"):
|
|
45
|
+
header_parts.append(f"[bold blue]{counts['low']} low[/bold blue]")
|
|
46
|
+
|
|
47
|
+
summary = " | ".join(header_parts)
|
|
48
|
+
|
|
49
|
+
console.print()
|
|
50
|
+
console.rule(style="dim")
|
|
51
|
+
console.print(
|
|
52
|
+
f" [bold]flagrant[/bold] | {total} issue{'s' if total != 1 else ''} flagged ({summary})",
|
|
53
|
+
)
|
|
54
|
+
console.rule(style="dim")
|
|
55
|
+
|
|
56
|
+
# Issues
|
|
57
|
+
for issue in issues:
|
|
58
|
+
style, bullet, label = SEVERITY_STYLES.get(
|
|
59
|
+
issue.severity, ("dim", "○", "??? ")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
line_str = f" line {issue.line}" if issue.line else ""
|
|
63
|
+
location = f"{issue.file}{line_str}"
|
|
64
|
+
|
|
65
|
+
console.print()
|
|
66
|
+
console.print(f" [{style}]{bullet} {label}[/{style}] [bold]{location}[/bold]")
|
|
67
|
+
console.print(f" {issue.issue}")
|
|
68
|
+
if issue.fix:
|
|
69
|
+
console.print(f" [dim]Fix: {issue.fix}[/dim]")
|
|
70
|
+
|
|
71
|
+
if explain and issue.explanation:
|
|
72
|
+
console.print(f" [italic cyan]{issue.explanation}[/italic cyan]")
|
|
73
|
+
|
|
74
|
+
console.print()
|
|
75
|
+
console.rule(style="dim")
|
|
76
|
+
console.print()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def display_error(message: str) -> None:
|
|
80
|
+
"""Print an error."""
|
|
81
|
+
console.print(f"\n[bold red]error: {message}[/bold red]\n")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def display_info(message: str) -> None:
|
|
85
|
+
"""Print an info message."""
|
|
86
|
+
console.print(f"\n[dim]{message}[/dim]\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def display_success(message: str) -> None:
|
|
90
|
+
"""Print a success message."""
|
|
91
|
+
console.print(f"\n[green]{message}[/green]\n")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Git helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from git import Repo, InvalidGitRepositoryError
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BINARY_EXTENSIONS = {
|
|
16
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp",
|
|
17
|
+
".mp3", ".mp4", ".avi", ".mov", ".wav", ".flac",
|
|
18
|
+
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
|
19
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
20
|
+
".exe", ".dll", ".so", ".dylib", ".o", ".a",
|
|
21
|
+
".pyc", ".pyo", ".class", ".wasm",
|
|
22
|
+
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
|
23
|
+
".sqlite", ".db",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def get_git_root(path: str = ".") -> Optional[Path]:
|
|
27
|
+
"""Find the git repo root from the given path."""
|
|
28
|
+
try:
|
|
29
|
+
repo = Repo(path, search_parent_directories=True)
|
|
30
|
+
return Path(repo.working_dir)
|
|
31
|
+
except InvalidGitRepositoryError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_binary_path(filepath: str) -> bool:
|
|
36
|
+
"""Check if a file is likely binary based on extension."""
|
|
37
|
+
return Path(filepath).suffix.lower() in BINARY_EXTENSIONS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_binary_content(filepath: Path) -> bool:
|
|
41
|
+
"""Check for null bytes."""
|
|
42
|
+
try:
|
|
43
|
+
with open(filepath, "rb") as f:
|
|
44
|
+
chunk = f.read(8192)
|
|
45
|
+
return b"\x00" in chunk
|
|
46
|
+
except OSError:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_staged_diff(repo_path: str = ".") -> Optional[str]:
|
|
51
|
+
"""Get the unified diff of all staged changes."""
|
|
52
|
+
try:
|
|
53
|
+
repo = Repo(repo_path, search_parent_directories=True)
|
|
54
|
+
diff = repo.git.diff("--cached", "--unified=3")
|
|
55
|
+
return diff if diff.strip() else None
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_staged_files(repo_path: str = ".") -> list[dict]:
|
|
61
|
+
"""Get staged file contents. Skips binaries."""
|
|
62
|
+
files = []
|
|
63
|
+
try:
|
|
64
|
+
repo = Repo(repo_path, search_parent_directories=True)
|
|
65
|
+
root = Path(repo.working_dir)
|
|
66
|
+
|
|
67
|
+
# Get list of staged file paths
|
|
68
|
+
staged = repo.git.diff("--cached", "--name-only").strip()
|
|
69
|
+
if not staged:
|
|
70
|
+
return files
|
|
71
|
+
|
|
72
|
+
for rel_path in staged.splitlines():
|
|
73
|
+
rel_path = rel_path.strip()
|
|
74
|
+
if not rel_path or _is_binary_path(rel_path):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
full_path = root / rel_path
|
|
78
|
+
if not full_path.exists() or _is_binary_content(full_path):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
83
|
+
files.append({"path": rel_path, "content": content})
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return files
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_repo_files(
|
|
94
|
+
path: str = ".",
|
|
95
|
+
ignore_patterns: list[str] | None = None,
|
|
96
|
+
) -> list[dict]:
|
|
97
|
+
"""Get all tracked non-binary files in the repo."""
|
|
98
|
+
files = []
|
|
99
|
+
ignore_patterns = ignore_patterns or []
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
repo = Repo(path, search_parent_directories=True)
|
|
103
|
+
root = Path(repo.working_dir)
|
|
104
|
+
|
|
105
|
+
tracked = repo.git.ls_files().strip()
|
|
106
|
+
if not tracked:
|
|
107
|
+
return files
|
|
108
|
+
|
|
109
|
+
for rel_path in tracked.splitlines():
|
|
110
|
+
rel_path = rel_path.strip()
|
|
111
|
+
if not rel_path or _is_binary_path(rel_path):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Check ignore patterns
|
|
115
|
+
if any(_matches_ignore(rel_path, pat) for pat in ignore_patterns):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
full_path = root / rel_path
|
|
119
|
+
if not full_path.exists() or _is_binary_content(full_path):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
124
|
+
files.append({"path": rel_path, "content": content})
|
|
125
|
+
except OSError:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return files
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_single_file(filepath: str) -> Optional[dict]:
|
|
135
|
+
"""Read a single file, returning {path, content} or None."""
|
|
136
|
+
p = Path(filepath)
|
|
137
|
+
if not p.exists():
|
|
138
|
+
return None
|
|
139
|
+
if _is_binary_path(filepath) or _is_binary_content(p):
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
content = p.read_text(encoding="utf-8", errors="replace")
|
|
143
|
+
return {"path": str(p), "content": content}
|
|
144
|
+
except OSError:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _matches_ignore(filepath: str, pattern: str) -> bool:
|
|
149
|
+
"""Check if filepath matches an ignore pattern."""
|
|
150
|
+
pattern = pattern.rstrip("/")
|
|
151
|
+
return filepath.startswith(pattern) or f"/{pattern}" in filepath
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""CLI entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.spinner import Spinner
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
|
|
17
|
+
from flagrant.config import (
|
|
18
|
+
get_app_config,
|
|
19
|
+
load_project_config,
|
|
20
|
+
setup_api_key_interactive,
|
|
21
|
+
AppConfig,
|
|
22
|
+
PROVIDERS,
|
|
23
|
+
)
|
|
24
|
+
from flagrant.git_utils import (
|
|
25
|
+
get_git_root,
|
|
26
|
+
get_staged_diff,
|
|
27
|
+
get_staged_files,
|
|
28
|
+
get_repo_files,
|
|
29
|
+
read_single_file,
|
|
30
|
+
)
|
|
31
|
+
from flagrant.reviewer import review_code, review_diff, Issue
|
|
32
|
+
from flagrant.formatter import (
|
|
33
|
+
display_issues,
|
|
34
|
+
display_error,
|
|
35
|
+
display_info,
|
|
36
|
+
display_success,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
|
|
41
|
+
_SUBCOMMANDS = {"config", "install-hook", "remove-hook"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_review(
|
|
45
|
+
files: list[dict],
|
|
46
|
+
app_config: AppConfig,
|
|
47
|
+
strict: bool,
|
|
48
|
+
explain: bool,
|
|
49
|
+
diff_mode: bool,
|
|
50
|
+
language: str,
|
|
51
|
+
diff_text: Optional[str] = None,
|
|
52
|
+
) -> list[Issue]:
|
|
53
|
+
"""Run the review, show a spinner while waiting."""
|
|
54
|
+
provider_name = PROVIDERS[app_config.provider]["name"]
|
|
55
|
+
model = app_config.effective_model
|
|
56
|
+
|
|
57
|
+
with Live(
|
|
58
|
+
Spinner("dots", text=f" [dim]Reviewing with {provider_name} ({model})...[/dim]"),
|
|
59
|
+
console=console,
|
|
60
|
+
transient=True,
|
|
61
|
+
):
|
|
62
|
+
if diff_mode and diff_text:
|
|
63
|
+
issues = review_diff(
|
|
64
|
+
diff_text=diff_text,
|
|
65
|
+
app_config=app_config,
|
|
66
|
+
strict=strict,
|
|
67
|
+
explain=explain,
|
|
68
|
+
language=language,
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
issues = review_code(
|
|
72
|
+
files=files,
|
|
73
|
+
app_config=app_config,
|
|
74
|
+
strict=strict,
|
|
75
|
+
explain=explain,
|
|
76
|
+
diff_mode=diff_mode,
|
|
77
|
+
language=language,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return issues
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def do_review(
|
|
84
|
+
path: Optional[str] = None,
|
|
85
|
+
staged: bool = False,
|
|
86
|
+
file: Optional[str] = None,
|
|
87
|
+
strict: bool = False,
|
|
88
|
+
explain: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Run a review based on CLI args."""
|
|
91
|
+
# Default to current dir
|
|
92
|
+
if path is None and not staged and file is None:
|
|
93
|
+
path = "."
|
|
94
|
+
|
|
95
|
+
# Load configs
|
|
96
|
+
try:
|
|
97
|
+
app_config = get_app_config()
|
|
98
|
+
except SystemExit:
|
|
99
|
+
raise typer.Exit(1)
|
|
100
|
+
|
|
101
|
+
repo_root = get_git_root(path or ".")
|
|
102
|
+
project_config = load_project_config(repo_root) if repo_root else None
|
|
103
|
+
|
|
104
|
+
# Merge project config with CLI flags
|
|
105
|
+
if project_config:
|
|
106
|
+
strict = strict or project_config.strict
|
|
107
|
+
explain = explain or project_config.explain
|
|
108
|
+
language = project_config.language
|
|
109
|
+
ignore_patterns = project_config.ignore
|
|
110
|
+
else:
|
|
111
|
+
language = "auto"
|
|
112
|
+
ignore_patterns = []
|
|
113
|
+
|
|
114
|
+
# Determine what to review
|
|
115
|
+
files: list[dict] = []
|
|
116
|
+
diff_text: Optional[str] = None
|
|
117
|
+
diff_mode = False
|
|
118
|
+
|
|
119
|
+
if file:
|
|
120
|
+
# Single file mode
|
|
121
|
+
result = read_single_file(file)
|
|
122
|
+
if not result:
|
|
123
|
+
display_error(f"Cannot read file: {file}")
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
files = [result]
|
|
126
|
+
elif staged:
|
|
127
|
+
# staged changes -- review the diff
|
|
128
|
+
diff_text = get_staged_diff(path or ".")
|
|
129
|
+
if not diff_text:
|
|
130
|
+
display_info("nothing staged")
|
|
131
|
+
raise typer.Exit(0)
|
|
132
|
+
diff_mode = True
|
|
133
|
+
else:
|
|
134
|
+
# Full repo/directory scan
|
|
135
|
+
target = path or "."
|
|
136
|
+
target_path = Path(target)
|
|
137
|
+
|
|
138
|
+
if target_path.is_file():
|
|
139
|
+
result = read_single_file(str(target_path))
|
|
140
|
+
if not result:
|
|
141
|
+
display_error(f"Cannot read file: {target}")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
files = [result]
|
|
144
|
+
else:
|
|
145
|
+
files = get_repo_files(target, ignore_patterns=ignore_patterns)
|
|
146
|
+
if not files:
|
|
147
|
+
display_info("no reviewable files found")
|
|
148
|
+
raise typer.Exit(0)
|
|
149
|
+
|
|
150
|
+
# limit how much we send to the api
|
|
151
|
+
if len(files) > 50:
|
|
152
|
+
console.print(
|
|
153
|
+
f"[yellow]{len(files)} files found, reviewing first 50. "
|
|
154
|
+
f"use --file or --staged to narrow scope.[/yellow]"
|
|
155
|
+
)
|
|
156
|
+
files = files[:50]
|
|
157
|
+
|
|
158
|
+
# Run review
|
|
159
|
+
try:
|
|
160
|
+
issues = _run_review(
|
|
161
|
+
files=files,
|
|
162
|
+
app_config=app_config,
|
|
163
|
+
strict=strict,
|
|
164
|
+
explain=explain,
|
|
165
|
+
diff_mode=diff_mode,
|
|
166
|
+
language=language,
|
|
167
|
+
diff_text=diff_text,
|
|
168
|
+
)
|
|
169
|
+
except RuntimeError:
|
|
170
|
+
display_error("review failed. check your API key and account balance.")
|
|
171
|
+
raise typer.Exit(1)
|
|
172
|
+
|
|
173
|
+
# Display results
|
|
174
|
+
display_issues(issues, explain=explain)
|
|
175
|
+
|
|
176
|
+
# Exit code 1 if high severity issues found
|
|
177
|
+
if any(i.severity == "high" for i in issues):
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class FlagrantCLI(click.Group):
|
|
184
|
+
"""Routes bare args to the review subcommand."""
|
|
185
|
+
|
|
186
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
187
|
+
if args:
|
|
188
|
+
|
|
189
|
+
first_non_opt = None
|
|
190
|
+
for a in args:
|
|
191
|
+
if not a.startswith("-"):
|
|
192
|
+
first_non_opt = a
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
if first_non_opt is None or first_non_opt not in self.commands:
|
|
196
|
+
args = ["review"] + args
|
|
197
|
+
|
|
198
|
+
if not args:
|
|
199
|
+
args = ["review"]
|
|
200
|
+
|
|
201
|
+
return super().parse_args(ctx, args)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@click.group(cls=FlagrantCLI)
|
|
205
|
+
def app():
|
|
206
|
+
"""flagrant - local AI code reviewer."""
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command("review")
|
|
211
|
+
@click.argument("path", required=False, default=None)
|
|
212
|
+
@click.option("--staged", "-s", is_flag=True, help="Only review staged git changes.")
|
|
213
|
+
@click.option("--file", "-f", "file_path", default=None, help="Review a single file.")
|
|
214
|
+
@click.option("--strict", is_flag=True, help="Security-focused review pass.")
|
|
215
|
+
@click.option("--explain", "-e", is_flag=True, help="Include teaching explanations.")
|
|
216
|
+
def review_cmd(path, staged, file_path, strict, explain):
|
|
217
|
+
"""Review code for issues like a senior developer would."""
|
|
218
|
+
do_review(path=path, staged=staged, file=file_path, strict=strict, explain=explain)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("config")
|
|
222
|
+
def configure():
|
|
223
|
+
"""Reconfigure API key and provider settings."""
|
|
224
|
+
setup_api_key_interactive()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
PRE_COMMIT_HOOK = """#!/bin/sh
|
|
228
|
+
# flagrant pre-commit hook
|
|
229
|
+
# Installed by: flagrant install-hook
|
|
230
|
+
|
|
231
|
+
flagrant --staged
|
|
232
|
+
exit_code=$?
|
|
233
|
+
|
|
234
|
+
if [ $exit_code -ne 0 ]; then
|
|
235
|
+
echo ""
|
|
236
|
+
echo "flagrant: commit blocked (high severity issues found)"
|
|
237
|
+
echo " Fix the issues above, or commit with --no-verify to skip."
|
|
238
|
+
echo ""
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
exit $exit_code
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("install-hook")
|
|
246
|
+
def install_hook():
|
|
247
|
+
"""Install a pre-commit git hook that runs flagrant --staged."""
|
|
248
|
+
repo_root = get_git_root(".")
|
|
249
|
+
if not repo_root:
|
|
250
|
+
display_error("Not inside a git repository.")
|
|
251
|
+
raise SystemExit(1)
|
|
252
|
+
|
|
253
|
+
hooks_dir = repo_root / ".git" / "hooks"
|
|
254
|
+
hook_path = hooks_dir / "pre-commit"
|
|
255
|
+
|
|
256
|
+
if hook_path.exists():
|
|
257
|
+
content = hook_path.read_text()
|
|
258
|
+
if "flagrant" in content.lower():
|
|
259
|
+
display_info("Flagrant pre-commit hook is already installed.")
|
|
260
|
+
return
|
|
261
|
+
else:
|
|
262
|
+
display_error(
|
|
263
|
+
f"A pre-commit hook already exists at {hook_path}.\n"
|
|
264
|
+
" Back it up or remove it, then try again."
|
|
265
|
+
)
|
|
266
|
+
raise SystemExit(1)
|
|
267
|
+
|
|
268
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
hook_path.write_text(PRE_COMMIT_HOOK)
|
|
270
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
|
|
271
|
+
|
|
272
|
+
display_success(f"Pre-commit hook installed at {hook_path}")
|
|
273
|
+
console.print(
|
|
274
|
+
"[dim] It will run [bold]flagrant --staged[/bold] before every commit.\n"
|
|
275
|
+
" Use [bold]git commit --no-verify[/bold] to skip.[/dim]\n"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command("remove-hook")
|
|
280
|
+
def remove_hook():
|
|
281
|
+
"""Remove the flagrant pre-commit hook."""
|
|
282
|
+
repo_root = get_git_root(".")
|
|
283
|
+
if not repo_root:
|
|
284
|
+
display_error("Not inside a git repository.")
|
|
285
|
+
raise SystemExit(1)
|
|
286
|
+
|
|
287
|
+
hook_path = repo_root / ".git" / "hooks" / "pre-commit"
|
|
288
|
+
|
|
289
|
+
if not hook_path.exists():
|
|
290
|
+
display_info("no pre-commit hook found.")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
content = hook_path.read_text()
|
|
294
|
+
if "flagrant" not in content.lower():
|
|
295
|
+
display_error(
|
|
296
|
+
"The existing pre-commit hook was not installed by Flagrant.\n"
|
|
297
|
+
" Remove it manually if needed."
|
|
298
|
+
)
|
|
299
|
+
raise SystemExit(1)
|
|
300
|
+
|
|
301
|
+
hook_path.unlink()
|
|
302
|
+
display_success("Pre-commit hook removed.")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
app()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Prompt construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
BASE_PROMPT = """You are a senior software engineer doing a code review. Be direct and blunt.
|
|
7
|
+
You care about real, practical issues, not nitpicking style and formatting.
|
|
8
|
+
|
|
9
|
+
For each issue found return a JSON array in this exact format:
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
"severity": "high|medium|low",
|
|
13
|
+
"file": "filename",
|
|
14
|
+
"line": <line_number_or_null>,
|
|
15
|
+
"issue": "one sentence describing the problem",
|
|
16
|
+
"fix": "one sentence describing the solution"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
Return ONLY the JSON array. No preamble. No markdown. No commentary.
|
|
21
|
+
If you find no issues, return an empty array: []
|
|
22
|
+
|
|
23
|
+
Severity rules:
|
|
24
|
+
- high: security vulnerabilities, data loss risk, crashes, broken logic
|
|
25
|
+
- medium: bad patterns, missing error handling, performance issues, race conditions
|
|
26
|
+
- low: unused code, minor improvements, documentation gaps
|
|
27
|
+
|
|
28
|
+
Be selective. Only flag things that actually matter. Do NOT flag:
|
|
29
|
+
- Style preferences or formatting
|
|
30
|
+
- Missing type hints (unless causing bugs)
|
|
31
|
+
- Things that are obviously intentional
|
|
32
|
+
- Test files unless they have actual bugs"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
STRICT_ADDON = """
|
|
36
|
+
|
|
37
|
+
STRICT MODE - security-focused review. Prioritize:
|
|
38
|
+
- SQL injection, XSS, command injection, path traversal
|
|
39
|
+
- Hardcoded secrets, credentials, API keys in code
|
|
40
|
+
- Insecure crypto, weak hashing, cleartext passwords
|
|
41
|
+
- SSRF, open redirects, insecure deserialization
|
|
42
|
+
- Missing input validation on user-facing endpoints
|
|
43
|
+
- Overly permissive file/network access
|
|
44
|
+
Flag ALL security concerns as HIGH severity."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
EXPLAIN_ADDON = """
|
|
48
|
+
|
|
49
|
+
For EVERY issue, include an additional field:
|
|
50
|
+
"explanation": "2-3 sentences teaching why this matters and what could go wrong"
|
|
51
|
+
|
|
52
|
+
This is for educational purposes - explain like you're mentoring a junior developer."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DIFF_MODE_ADDON = """
|
|
56
|
+
|
|
57
|
+
You are reviewing a git diff. Focus ONLY on the changed lines (lines starting with +).
|
|
58
|
+
The file paths and line numbers are shown in the diff headers.
|
|
59
|
+
Do not flag issues in unchanged code (context lines) unless a change introduced a bug that interacts with existing code."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_system_prompt(
|
|
63
|
+
strict: bool = False,
|
|
64
|
+
explain: bool = False,
|
|
65
|
+
diff_mode: bool = False,
|
|
66
|
+
language: str = "auto",
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Build the full system prompt with optional addons."""
|
|
69
|
+
prompt = BASE_PROMPT
|
|
70
|
+
|
|
71
|
+
if language != "auto":
|
|
72
|
+
prompt += f"\n\nThe codebase is primarily written in {language}."
|
|
73
|
+
|
|
74
|
+
if diff_mode:
|
|
75
|
+
prompt += DIFF_MODE_ADDON
|
|
76
|
+
|
|
77
|
+
if strict:
|
|
78
|
+
prompt += STRICT_ADDON
|
|
79
|
+
|
|
80
|
+
if explain:
|
|
81
|
+
prompt += EXPLAIN_ADDON
|
|
82
|
+
|
|
83
|
+
return prompt
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Review logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from flagrant.config import AppConfig, PROVIDERS
|
|
14
|
+
from flagrant.prompt import build_system_prompt
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
MAX_LINES_PER_CHUNK = 3000
|
|
19
|
+
MAX_RETRIES = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Issue:
|
|
24
|
+
"""A single code review issue."""
|
|
25
|
+
severity: str # high, medium, low
|
|
26
|
+
file: str
|
|
27
|
+
line: Optional[int]
|
|
28
|
+
issue: str
|
|
29
|
+
fix: str
|
|
30
|
+
explanation: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def chunk_files(files: list[dict], max_lines: int = MAX_LINES_PER_CHUNK) -> list[str]:
|
|
34
|
+
"""Split files into chunks that fit context limits."""
|
|
35
|
+
chunks = []
|
|
36
|
+
current_chunk_lines = 0
|
|
37
|
+
current_chunk_parts = []
|
|
38
|
+
|
|
39
|
+
for f in files:
|
|
40
|
+
content = f["content"]
|
|
41
|
+
lines = content.splitlines()
|
|
42
|
+
file_header = f"--- FILE: {f['path']} ---"
|
|
43
|
+
|
|
44
|
+
# If single file exceeds max, split it
|
|
45
|
+
if len(lines) > max_lines:
|
|
46
|
+
# Flush current chunk first
|
|
47
|
+
if current_chunk_parts:
|
|
48
|
+
chunks.append("\n".join(current_chunk_parts))
|
|
49
|
+
current_chunk_parts = []
|
|
50
|
+
current_chunk_lines = 0
|
|
51
|
+
|
|
52
|
+
for i in range(0, len(lines), max_lines):
|
|
53
|
+
slice_lines = lines[i:i + max_lines]
|
|
54
|
+
part_header = f"{file_header} (lines {i + 1}-{i + len(slice_lines)})"
|
|
55
|
+
chunks.append(part_header + "\n" + "\n".join(slice_lines))
|
|
56
|
+
else:
|
|
57
|
+
# Would adding this file exceed the limit?
|
|
58
|
+
if current_chunk_lines + len(lines) > max_lines and current_chunk_parts:
|
|
59
|
+
chunks.append("\n".join(current_chunk_parts))
|
|
60
|
+
current_chunk_parts = []
|
|
61
|
+
current_chunk_lines = 0
|
|
62
|
+
|
|
63
|
+
current_chunk_parts.append(file_header + "\n" + content)
|
|
64
|
+
current_chunk_lines += len(lines)
|
|
65
|
+
|
|
66
|
+
# Flush remaining
|
|
67
|
+
if current_chunk_parts:
|
|
68
|
+
chunks.append("\n".join(current_chunk_parts))
|
|
69
|
+
|
|
70
|
+
return chunks
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_issues(raw: str) -> list[Issue]:
|
|
74
|
+
"""Parse JSON issues from LLM response. Handles messy output."""
|
|
75
|
+
raw = raw.strip()
|
|
76
|
+
|
|
77
|
+
# Try direct parse first
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw)
|
|
80
|
+
if isinstance(data, list):
|
|
81
|
+
return [_dict_to_issue(d) for d in data if isinstance(d, dict)]
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# Try to extract JSON array from surrounding text
|
|
86
|
+
match = re.search(r"\[.*\]", raw, re.DOTALL)
|
|
87
|
+
if match:
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(match.group())
|
|
90
|
+
if isinstance(data, list):
|
|
91
|
+
return [_dict_to_issue(d) for d in data if isinstance(d, dict)]
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# If we got markdown-wrapped JSON, strip it
|
|
96
|
+
cleaned = re.sub(r"```(?:json)?\s*", "", raw)
|
|
97
|
+
cleaned = cleaned.strip().rstrip("`")
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(cleaned)
|
|
100
|
+
if isinstance(data, list):
|
|
101
|
+
return [_dict_to_issue(d) for d in data if isinstance(d, dict)]
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _dict_to_issue(d: dict) -> Issue:
|
|
109
|
+
return Issue(
|
|
110
|
+
severity=d.get("severity", "low").lower().strip(),
|
|
111
|
+
file=d.get("file", "unknown"),
|
|
112
|
+
line=d.get("line"),
|
|
113
|
+
issue=d.get("issue", ""),
|
|
114
|
+
fix=d.get("fix", ""),
|
|
115
|
+
explanation=d.get("explanation"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _call_claude(
|
|
120
|
+
app_config: AppConfig,
|
|
121
|
+
system_prompt: str,
|
|
122
|
+
user_content: str,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Call Anthropic Claude API."""
|
|
125
|
+
from anthropic import Anthropic
|
|
126
|
+
|
|
127
|
+
client = Anthropic(api_key=app_config.api_key)
|
|
128
|
+
response = client.messages.create(
|
|
129
|
+
model=app_config.effective_model,
|
|
130
|
+
max_tokens=4096,
|
|
131
|
+
system=system_prompt,
|
|
132
|
+
messages=[{"role": "user", "content": user_content}],
|
|
133
|
+
)
|
|
134
|
+
return response.content[0].text
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _call_openai(
|
|
138
|
+
app_config: AppConfig,
|
|
139
|
+
system_prompt: str,
|
|
140
|
+
user_content: str,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Call OpenAI API."""
|
|
143
|
+
from openai import OpenAI
|
|
144
|
+
|
|
145
|
+
client = OpenAI(api_key=app_config.api_key)
|
|
146
|
+
response = client.chat.completions.create(
|
|
147
|
+
model=app_config.effective_model,
|
|
148
|
+
max_tokens=4096,
|
|
149
|
+
messages=[
|
|
150
|
+
{"role": "system", "content": system_prompt},
|
|
151
|
+
{"role": "user", "content": user_content},
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
return response.choices[0].message.content
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _call_gemini(
|
|
158
|
+
app_config: AppConfig,
|
|
159
|
+
system_prompt: str,
|
|
160
|
+
user_content: str,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Call Google Gemini API."""
|
|
163
|
+
from google import genai
|
|
164
|
+
|
|
165
|
+
client = genai.Client(api_key=app_config.api_key)
|
|
166
|
+
response = client.models.generate_content(
|
|
167
|
+
model=app_config.effective_model,
|
|
168
|
+
contents=user_content,
|
|
169
|
+
config=genai.types.GenerateContentConfig(
|
|
170
|
+
system_instruction=system_prompt,
|
|
171
|
+
max_output_tokens=4096,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
return response.text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _call_deepseek(
|
|
178
|
+
app_config: AppConfig,
|
|
179
|
+
system_prompt: str,
|
|
180
|
+
user_content: str,
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Call DeepSeek API (OpenAI-compatible)."""
|
|
183
|
+
from openai import OpenAI
|
|
184
|
+
|
|
185
|
+
client = OpenAI(
|
|
186
|
+
api_key=app_config.api_key,
|
|
187
|
+
base_url="https://api.deepseek.com",
|
|
188
|
+
)
|
|
189
|
+
response = client.chat.completions.create(
|
|
190
|
+
model=app_config.effective_model,
|
|
191
|
+
max_tokens=4096,
|
|
192
|
+
messages=[
|
|
193
|
+
{"role": "system", "content": system_prompt},
|
|
194
|
+
{"role": "user", "content": user_content},
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
return response.choices[0].message.content
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
PROVIDER_CALLERS = {
|
|
201
|
+
"claude": _call_claude,
|
|
202
|
+
"openai": _call_openai,
|
|
203
|
+
"gemini": _call_gemini,
|
|
204
|
+
"deepseek": _call_deepseek,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _call_llm(
|
|
209
|
+
app_config: AppConfig,
|
|
210
|
+
system_prompt: str,
|
|
211
|
+
user_content: str,
|
|
212
|
+
) -> str:
|
|
213
|
+
"""Call the configured provider. Retries once on failure."""
|
|
214
|
+
caller = PROVIDER_CALLERS.get(app_config.provider)
|
|
215
|
+
if not caller:
|
|
216
|
+
raise ValueError(f"Unknown provider: {app_config.provider}")
|
|
217
|
+
|
|
218
|
+
last_error = None
|
|
219
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
220
|
+
try:
|
|
221
|
+
return caller(app_config, system_prompt, user_content)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
last_error = e
|
|
224
|
+
if attempt < MAX_RETRIES:
|
|
225
|
+
console.print(f"[yellow]warning: API call failed, retrying... ({e})[/yellow]")
|
|
226
|
+
time.sleep(2)
|
|
227
|
+
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"API call failed after {MAX_RETRIES + 1} attempts: {last_error}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def review_code(
|
|
234
|
+
files: list[dict],
|
|
235
|
+
app_config: AppConfig,
|
|
236
|
+
strict: bool = False,
|
|
237
|
+
explain: bool = False,
|
|
238
|
+
diff_mode: bool = False,
|
|
239
|
+
language: str = "auto",
|
|
240
|
+
) -> list[Issue]:
|
|
241
|
+
"""Review files. Chunks large inputs, calls the LLM, parses results."""
|
|
242
|
+
if not files:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
system_prompt = build_system_prompt(
|
|
246
|
+
strict=strict,
|
|
247
|
+
explain=explain,
|
|
248
|
+
diff_mode=diff_mode,
|
|
249
|
+
language=language,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
chunks = chunk_files(files)
|
|
253
|
+
all_issues: list[Issue] = []
|
|
254
|
+
has_error = False
|
|
255
|
+
|
|
256
|
+
for i, chunk in enumerate(chunks):
|
|
257
|
+
if len(chunks) > 1:
|
|
258
|
+
console.print(
|
|
259
|
+
f"[dim] scanning chunk {i + 1}/{len(chunks)}...[/dim]"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
user_msg = "Review the following code:\n\n" + chunk
|
|
263
|
+
try:
|
|
264
|
+
raw = _call_llm(app_config, system_prompt, user_msg)
|
|
265
|
+
issues = _parse_issues(raw)
|
|
266
|
+
all_issues.extend(issues)
|
|
267
|
+
except RuntimeError as e:
|
|
268
|
+
console.print(f"[red]error: {e}[/red]")
|
|
269
|
+
has_error = True
|
|
270
|
+
|
|
271
|
+
if has_error and not all_issues:
|
|
272
|
+
raise RuntimeError("Review failed, could not reach the API.")
|
|
273
|
+
|
|
274
|
+
return all_issues
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def review_diff(
|
|
278
|
+
diff_text: str,
|
|
279
|
+
app_config: AppConfig,
|
|
280
|
+
strict: bool = False,
|
|
281
|
+
explain: bool = False,
|
|
282
|
+
language: str = "auto",
|
|
283
|
+
) -> list[Issue]:
|
|
284
|
+
"""Review a git diff string."""
|
|
285
|
+
system_prompt = build_system_prompt(
|
|
286
|
+
strict=strict,
|
|
287
|
+
explain=explain,
|
|
288
|
+
diff_mode=True,
|
|
289
|
+
language=language,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
user_msg = "Review the following git diff:\n\n" + diff_text
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
raw = _call_llm(app_config, system_prompt, user_msg)
|
|
296
|
+
return _parse_issues(raw)
|
|
297
|
+
except RuntimeError as e:
|
|
298
|
+
console.print(f"[red]error: {e}[/red]")
|
|
299
|
+
raise
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flagrant"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local AI code reviewer for pre-commit checks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Flagrant" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["code-review", "ai", "cli", "linting", "developer-tools"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"typer[all]>=0.9.0",
|
|
28
|
+
"rich>=13.0.0",
|
|
29
|
+
"gitpython>=3.1.0",
|
|
30
|
+
"anthropic>=0.40.0",
|
|
31
|
+
"openai>=1.0.0",
|
|
32
|
+
"google-genai>=1.0.0",
|
|
33
|
+
"python-dotenv>=1.0.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
flagrant = "flagrant.main:app"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/flagrant/flagrant"
|
|
41
|
+
Repository = "https://github.com/flagrant/flagrant"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# tests
|