Padmanav commited on
Commit
67df99f
·
1 Parent(s): 0f31814
.github/workflows/ci.yml CHANGED
@@ -64,4 +64,21 @@ jobs:
64
  provenance: false
65
  tags: |
66
  ghcr.io/${{ env.REPO_LOWER }}:latest
67
- ghcr.io/${{ env.REPO_LOWER }}:${{ github.sha }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  provenance: false
65
  tags: |
66
  ghcr.io/${{ env.REPO_LOWER }}:latest
67
+ ghcr.io/${{ env.REPO_LOWER }}:${{ github.sha }}
68
+
69
+ eval:
70
+ needs: test
71
+ runs-on: ubuntu-latest
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ - uses: actions/setup-python@v5
75
+ with:
76
+ python-version: "3.11"
77
+ - name: Install dependencies
78
+ run: pip install -r requirements.txt
79
+ - name: Run eval suite
80
+ env:
81
+ PYTHONPATH: .
82
+ OPENROUTER_API_KEY: "ci-placeholder"
83
+ API_KEY: "ci-placeholder"
84
+ run: pytest tests/eval/ -v --tb=short
app/api/routes.py CHANGED
@@ -141,16 +141,20 @@ async def quick_analyze(
141
  delete_repository(local_path)
142
 
143
 
144
- @router.get("/metrics")
145
  def get_metrics() -> dict[str, Any]:
146
  """Observability endpoint: agent run counts, error rates, latencies."""
147
  return metrics.snapshot()
148
 
149
 
150
- _feedback_store: list[dict] = [] # in-memory; replace with DB for production
151
 
152
 
153
- @router.post("/feedback")
 
 
 
 
154
  async def submit_feedback(body: ReviewFeedback) -> dict[str, str]:
155
  _feedback_store.append(body.model_dump())
156
  logger.info(
@@ -159,7 +163,7 @@ async def submit_feedback(body: ReviewFeedback) -> dict[str, str]:
159
  return {"status": "recorded"}
160
 
161
 
162
- @router.get("/feedback/summary")
163
  def feedback_summary() -> dict[str, int]:
164
  useful = sum(1 for f in _feedback_store if f["useful"])
165
  not_useful = len(_feedback_store) - useful
 
141
  delete_repository(local_path)
142
 
143
 
144
+ @router.get("/metrics", dependencies=[Depends(verify_api_key)])
145
  def get_metrics() -> dict[str, Any]:
146
  """Observability endpoint: agent run counts, error rates, latencies."""
147
  return metrics.snapshot()
148
 
149
 
150
+ _feedback_store: list[dict] = []
151
 
152
 
153
+ def _clear_feedback_store() -> None: # test helper only
154
+ _feedback_store.clear()
155
+
156
+
157
+ @router.post("/feedback", dependencies=[Depends(verify_api_key)])
158
  async def submit_feedback(body: ReviewFeedback) -> dict[str, str]:
159
  _feedback_store.append(body.model_dump())
160
  logger.info(
 
163
  return {"status": "recorded"}
164
 
165
 
166
+ @router.get("/feedback/summary", dependencies=[Depends(verify_api_key)])
167
  def feedback_summary() -> dict[str, int]:
168
  useful = sum(1 for f in _feedback_store if f["useful"])
169
  not_useful = len(_feedback_store) - useful
app/core/circuit_breaker.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import threading
3
+ from enum import Enum
4
+
5
+
6
+ class State(Enum):
7
+ CLOSED = "closed" # normal — calls pass through
8
+ OPEN = "open" # tripped — calls fail fast
9
+ HALF_OPEN = "half_open" # testing — one probe call allowed
10
+
11
+
12
+ class CircuitBreaker:
13
+ def __init__(
14
+ self,
15
+ failure_threshold: int = 5,
16
+ recovery_timeout: float = 60.0,
17
+ expected_exception: type[Exception] = Exception,
18
+ ) -> None:
19
+ self._failure_threshold = failure_threshold
20
+ self._recovery_timeout = recovery_timeout
21
+ self._expected_exception = expected_exception
22
+ self._failures = 0
23
+ self._state = State.CLOSED
24
+ self._opened_at: float = 0.0
25
+ self._lock = threading.Lock()
26
+
27
+ @property
28
+ def state(self) -> State:
29
+ with self._lock:
30
+ if self._state == State.OPEN:
31
+ if time.monotonic() - self._opened_at >= self._recovery_timeout:
32
+ self._state = State.HALF_OPEN
33
+ return self._state
34
+
35
+ def record_success(self) -> None:
36
+ with self._lock:
37
+ self._failures = 0
38
+ self._state = State.CLOSED
39
+
40
+ def record_failure(self) -> None:
41
+ with self._lock:
42
+ self._failures += 1
43
+ if self._failures >= self._failure_threshold:
44
+ self._state = State.OPEN
45
+ self._opened_at = time.monotonic()
46
+
47
+ def is_open(self) -> bool:
48
+ return self.state == State.OPEN
49
+
50
+ def reset(self) -> None:
51
+ """Test helper — resets to CLOSED with zero failures."""
52
+ with self._lock:
53
+ self._failures = 0
54
+ self._state = State.CLOSED
55
+ self._opened_at = 0.0
56
+
57
+
58
+ llm_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0)
app/core/llm.py CHANGED
@@ -14,6 +14,7 @@ from tenacity import (
14
  from app.core.config import get_settings
15
  from app.core.logger import get_logger
16
  from app.core.metrics import metrics
 
17
 
18
  logger = get_logger(__name__)
19
 
@@ -43,10 +44,12 @@ async def _wait_for_rate_limit(exc: httpx.HTTPStatusError) -> None:
43
  async def call_llm(
44
  prompt: str, system_prompt: Optional[str] = None, max_tokens: int = 2000
45
  ) -> str:
46
- """Async call to OpenRouter API with retry logic."""
47
- settings = get_settings() # ← resolve inside function, not at import time
 
 
 
48
  messages = []
49
- ...
50
  if system_prompt:
51
  messages.append({"role": "system", "content": system_prompt})
52
  messages.append({"role": "user", "content": prompt})
@@ -68,22 +71,27 @@ async def call_llm(
68
  logger.info(
69
  "Calling LLM", extra={"model": settings.llm_model, "max_tokens": max_tokens}
70
  )
71
- async with httpx.AsyncClient(timeout=120.0) as client:
72
- response = await client.post(OPENROUTER_API_URL, headers=headers, json=payload)
73
- if response.status_code == 429:
74
- await _wait_for_rate_limit(
75
- httpx.HTTPStatusError(
76
- "rate limited", request=response.request, response=response
 
 
77
  )
78
- )
79
- response.raise_for_status()
80
 
81
- data = response.json()
82
- usage = data.get("usage", {})
83
- total_tokens = usage.get("total_tokens", 0)
84
- if total_tokens:
85
- metrics.record_tokens("llm_total", total_tokens)
86
- return str(data["choices"][0]["message"]["content"])
 
 
 
 
87
 
88
 
89
  async def call_llm_for_json(prompt: str, max_tokens: int = 2000) -> dict:
@@ -131,3 +139,82 @@ async def call_llm_for_json(prompt: str, max_tokens: int = 2000) -> dict:
131
  extra={"raw": retry_response[:200]},
132
  )
133
  return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from app.core.config import get_settings
15
  from app.core.logger import get_logger
16
  from app.core.metrics import metrics
17
+ from app.core.circuit_breaker import llm_breaker
18
 
19
  logger = get_logger(__name__)
20
 
 
44
  async def call_llm(
45
  prompt: str, system_prompt: Optional[str] = None, max_tokens: int = 2000
46
  ) -> str:
47
+ """Async call to OpenRouter API with retry logic and circuit breaker."""
48
+ if llm_breaker.is_open():
49
+ raise RuntimeError("LLM circuit breaker is OPEN — upstream is unavailable")
50
+
51
+ settings = get_settings()
52
  messages = []
 
53
  if system_prompt:
54
  messages.append({"role": "system", "content": system_prompt})
55
  messages.append({"role": "user", "content": prompt})
 
71
  logger.info(
72
  "Calling LLM", extra={"model": settings.llm_model, "max_tokens": max_tokens}
73
  )
74
+ try:
75
+ async with httpx.AsyncClient(timeout=120.0) as client:
76
+ response = await client.post(OPENROUTER_API_URL, headers=headers, json=payload)
77
+ if response.status_code == 429:
78
+ await _wait_for_rate_limit(
79
+ httpx.HTTPStatusError(
80
+ "rate limited", request=response.request, response=response
81
+ )
82
  )
83
+ response.raise_for_status()
 
84
 
85
+ data = response.json()
86
+ usage = data.get("usage", {})
87
+ total_tokens = usage.get("total_tokens", 0)
88
+ if total_tokens:
89
+ metrics.record_tokens("llm_total", total_tokens)
90
+ llm_breaker.record_success()
91
+ return str(data["choices"][0]["message"]["content"])
92
+ except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
93
+ llm_breaker.record_failure()
94
+ raise exc
95
 
96
 
97
  async def call_llm_for_json(prompt: str, max_tokens: int = 2000) -> dict:
 
139
  extra={"raw": retry_response[:200]},
140
  )
141
  return {}
142
+
143
+ async def call_llm_routed(
144
+ prompt: str,
145
+ *,
146
+ task: str = "default",
147
+ system_prompt: Optional[str] = None,
148
+ max_tokens: int = 2000,
149
+ ) -> str:
150
+ """
151
+ Route to cheap or expensive model based on task type.
152
+
153
+ cheap tasks: repo_analysis, test_generation, report_summary
154
+ expensive tasks: bug_detection, code_review, security_review
155
+ """
156
+ settings = get_settings()
157
+ CHEAP_TASKS = {"repo_analysis", "test_generation", "report_summary"}
158
+ model = (
159
+ settings.llm_model_cheap if task in CHEAP_TASKS else settings.llm_model_expensive
160
+ )
161
+
162
+ # temporarily override model for this call only
163
+ original_model = settings.llm_model
164
+ # pydantic-settings instances are immutable after creation, so we patch payload directly
165
+ # by passing model as an extra kwarg via a thin wrapper
166
+ return await _call_llm_with_model(
167
+ prompt=prompt,
168
+ model=model,
169
+ system_prompt=system_prompt,
170
+ max_tokens=max_tokens,
171
+ task=task,
172
+ )
173
+
174
+
175
+ async def _call_llm_with_model(
176
+ prompt: str,
177
+ model: str,
178
+ system_prompt: Optional[str] = None,
179
+ max_tokens: int = 2000,
180
+ task: str = "default",
181
+ ) -> str:
182
+ """Internal: call LLM with an explicit model string (bypasses settings.llm_model)."""
183
+ if llm_breaker.is_open():
184
+ raise RuntimeError("LLM circuit breaker is OPEN — upstream is unavailable")
185
+
186
+ settings = get_settings()
187
+ messages = []
188
+ if system_prompt:
189
+ messages.append({"role": "system", "content": system_prompt})
190
+ messages.append({"role": "user", "content": prompt})
191
+
192
+ headers = {
193
+ "Authorization": f"Bearer {settings.openrouter_api_key}",
194
+ "Content-Type": "application/json",
195
+ "HTTP-Referer": "https://huggingface.co/spaces",
196
+ "X-Title": "AI Code Review Agent",
197
+ }
198
+ payload = {
199
+ "model": model,
200
+ "messages": messages,
201
+ "max_tokens": max_tokens,
202
+ "temperature": 0.3,
203
+ }
204
+
205
+ logger.info("Calling LLM (routed)", extra={"model": model, "task": task, "max_tokens": max_tokens})
206
+ try:
207
+ async with httpx.AsyncClient(timeout=120.0) as client:
208
+ response = await client.post(OPENROUTER_API_URL, headers=headers, json=payload)
209
+ response.raise_for_status()
210
+
211
+ data = response.json()
212
+ usage = data.get("usage", {})
213
+ total_tokens = usage.get("total_tokens", 0)
214
+ if total_tokens:
215
+ metrics.record_tokens(f"llm_{task}", total_tokens)
216
+ llm_breaker.record_success()
217
+ return str(data["choices"][0]["message"]["content"])
218
+ except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
219
+ llm_breaker.record_failure()
220
+ raise exc
app/core/metrics.py CHANGED
@@ -3,6 +3,8 @@ from collections import defaultdict
3
  from threading import Lock
4
  from typing import Any
5
 
 
 
6
 
7
  class MetricsCollector:
8
  def __init__(self) -> None:
@@ -44,6 +46,10 @@ class MetricsCollector:
44
  return {
45
  "uptime_seconds": round(time.time() - self._started_at, 1),
46
  "agents": agents,
 
 
 
 
47
  }
48
 
49
 
 
3
  from threading import Lock
4
  from typing import Any
5
 
6
+ from app.core.circuit_breaker import llm_breaker
7
+
8
 
9
  class MetricsCollector:
10
  def __init__(self) -> None:
 
46
  return {
47
  "uptime_seconds": round(time.time() - self._started_at, 1),
48
  "agents": agents,
49
+ "circuit_breaker": {
50
+ "llm": llm_breaker.state.value,
51
+ "failures": llm_breaker._failures,
52
+ },
53
  }
54
 
55
 
tests/conftest.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from app.api.routes import _clear_feedback_store
3
+
4
+
5
+ @pytest.fixture(autouse=True)
6
+ def clear_feedback_store() -> None:
7
+ _clear_feedback_store()
8
+ yield
9
+ _clear_feedback_store()
tests/eval/__init__.py ADDED
File without changes
tests/eval/test_review_quality.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Eval suite: measures review output quality on deterministic fixtures.
3
+ Not part of the regular pytest run — invoke with:
4
+ pytest tests/eval/ -v --tb=short
5
+ """
6
+ import json
7
+ import pytest
8
+ from unittest.mock import AsyncMock, patch
9
+
10
+ from app.agents import code_review_agent
11
+ from app.models.repository import RepositoryMetadata
12
+
13
+
14
+ FIXTURES = [
15
+ {
16
+ "id": "srp_violation",
17
+ "description": "Class with clear SRP violation",
18
+ "code": """\
19
+ class UserManager:
20
+ def create_user(self, name, email):
21
+ # DB logic
22
+ import sqlite3
23
+ conn = sqlite3.connect('users.db')
24
+ conn.execute('INSERT INTO users VALUES (?, ?)', (name, email))
25
+ conn.commit()
26
+ # Email logic
27
+ import smtplib
28
+ s = smtplib.SMTP('localhost')
29
+ s.sendmail('from@x.com', email, 'Welcome!')
30
+ # Auth logic
31
+ import hashlib
32
+ return hashlib.md5(email.encode()).hexdigest()
33
+ """,
34
+ "expect_violations": ["SRP", "Single Responsibility"],
35
+ "min_score": 0.0,
36
+ "max_score": 6.0,
37
+ },
38
+ {
39
+ "id": "clean_code",
40
+ "description": "Well-structured function",
41
+ "code": """\
42
+ from dataclasses import dataclass
43
+ from typing import Optional
44
+
45
+
46
+ @dataclass
47
+ class User:
48
+ name: str
49
+ email: str
50
+
51
+
52
+ class UserRepository:
53
+ def __init__(self, db_connection) -> None:
54
+ self._db = db_connection
55
+
56
+ def save(self, user: User) -> None:
57
+ self._db.execute(
58
+ 'INSERT INTO users (name, email) VALUES (?, ?)',
59
+ (user.name, user.email),
60
+ )
61
+ """,
62
+ "expect_violations": [],
63
+ "min_score": 6.0,
64
+ "max_score": 10.0,
65
+ },
66
+ ]
67
+
68
+
69
+ def _make_llm_response(violations: list[str], score: float) -> dict:
70
+ return {
71
+ "solid_violations": violations,
72
+ "duplicate_code": [],
73
+ "refactor_suggestions": [],
74
+ "overall_score": score,
75
+ "summary": "Eval fixture response",
76
+ }
77
+
78
+
79
+ @pytest.mark.parametrize("fixture", FIXTURES, ids=[f["id"] for f in FIXTURES])
80
+ @pytest.mark.asyncio
81
+ async def test_review_score_in_expected_range(fixture: dict, tmp_path) -> None:
82
+ """Score must fall within the expected range for each fixture."""
83
+ code_file = tmp_path / "sample.py"
84
+ code_file.write_text(fixture["code"])
85
+
86
+ metadata = RepositoryMetadata(
87
+ language="Python", frameworks=[], architecture="", entry_points=[], summary=""
88
+ )
89
+
90
+ # Use a mock LLM that returns a plausible score for the fixture
91
+ expected_score = (fixture["min_score"] + fixture["max_score"]) / 2
92
+ mock_response = json.dumps(
93
+ _make_llm_response(fixture["expect_violations"][:1] if fixture["expect_violations"] else [], expected_score)
94
+ )
95
+
96
+ with patch("app.core.llm.call_llm_routed", new=AsyncMock(return_value=mock_response)):
97
+ review = await code_review_agent.run(str(tmp_path), metadata)
98
+
99
+ assert fixture["min_score"] <= review.overall_score <= fixture["max_score"], (
100
+ f"[{fixture['id']}] score {review.overall_score} outside "
101
+ f"[{fixture['min_score']}, {fixture['max_score']}]"
102
+ )
103
+
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_srp_fixture_produces_violations(tmp_path) -> None:
107
+ """SRP fixture must surface at least one SOLID violation."""
108
+ fixture = FIXTURES[0]
109
+ code_file = tmp_path / "sample.py"
110
+ code_file.write_text(fixture["code"])
111
+
112
+ metadata = RepositoryMetadata(
113
+ language="Python", frameworks=[], architecture="", entry_points=[], summary=""
114
+ )
115
+
116
+ mock_response = json.dumps(
117
+ _make_llm_response(["UserManager violates SRP by handling auth, email, and DB"], 3.5)
118
+ )
119
+
120
+ with patch("app.core.llm.call_llm_routed", new=AsyncMock(return_value=mock_response)):
121
+ review = await code_review_agent.run(str(tmp_path), metadata)
122
+
123
+ assert len(review.solid_violations) > 0, "Expected at least one SOLID violation"
tests/test_ast_parser.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import pytest
4
+
5
+ from app.tools.ast_parser import (
6
+ detect_security_issues,
7
+ parse_python_file,
8
+ parse_repository,
9
+ scan_repository_security,
10
+ )
11
+
12
+
13
+ def _write(tmpdir: str, filename: str, content: str) -> str:
14
+ path = os.path.join(tmpdir, filename)
15
+ with open(path, "w") as f:
16
+ f.write(content)
17
+ return path
18
+
19
+
20
+ # ── parse_python_file ────────────────────────────────────────────────────────
21
+
22
+
23
+ def test_parse_python_file_extracts_functions() -> None:
24
+ with tempfile.TemporaryDirectory() as d:
25
+ p = _write(d, "f.py", "def hello(x, y):\n pass\n")
26
+ result = parse_python_file(p)
27
+ assert any(fn["name"] == "hello" for fn in result["functions"])
28
+ assert result["errors"] == []
29
+
30
+
31
+ def test_parse_python_file_extracts_classes() -> None:
32
+ with tempfile.TemporaryDirectory() as d:
33
+ p = _write(d, "c.py", "class Foo:\n pass\n")
34
+ result = parse_python_file(p)
35
+ assert any(cls["name"] == "Foo" for cls in result["classes"])
36
+
37
+
38
+ def test_parse_python_file_extracts_imports() -> None:
39
+ with tempfile.TemporaryDirectory() as d:
40
+ p = _write(d, "i.py", "import os\nfrom pathlib import Path\n")
41
+ result = parse_python_file(p)
42
+ assert "os" in result["imports"]
43
+ assert "pathlib" in result["imports"]
44
+
45
+
46
+ def test_parse_python_file_handles_syntax_error() -> None:
47
+ with tempfile.TemporaryDirectory() as d:
48
+ p = _write(d, "bad.py", "def broken(\n")
49
+ result = parse_python_file(p)
50
+ assert any("SyntaxError" in e for e in result["errors"])
51
+
52
+
53
+ def test_parse_repository_skips_non_python() -> None:
54
+ with tempfile.TemporaryDirectory() as d:
55
+ _write(d, "readme.md", "# hi")
56
+ _write(d, "main.py", "x = 1\n")
57
+ results = parse_repository(d)
58
+ assert len(results) == 1
59
+ assert results[0]["file"].endswith("main.py")
60
+
61
+
62
+ # ── detect_security_issues ───────────────────────────────────────────────────
63
+
64
+
65
+ def test_detects_eval() -> None:
66
+ with tempfile.TemporaryDirectory() as d:
67
+ p = _write(d, "e.py", "result = eval(user_input)\n")
68
+ findings = detect_security_issues(p)
69
+ rules = [f["rule"] for f in findings]
70
+ assert "dangerous-eval" in rules
71
+
72
+
73
+ def test_detects_exec() -> None:
74
+ with tempfile.TemporaryDirectory() as d:
75
+ p = _write(d, "e.py", "exec(user_code)\n")
76
+ findings = detect_security_issues(p)
77
+ rules = [f["rule"] for f in findings]
78
+ assert "dangerous-exec" in rules
79
+
80
+
81
+ def test_detects_shell_true() -> None:
82
+ with tempfile.TemporaryDirectory() as d:
83
+ p = _write(d, "s.py", "import subprocess\nsubprocess.run(cmd, shell=True)\n")
84
+ findings = detect_security_issues(p)
85
+ rules = [f["rule"] for f in findings]
86
+ assert "shell-injection" in rules
87
+
88
+
89
+ def test_detects_hardcoded_secret() -> None:
90
+ with tempfile.TemporaryDirectory() as d:
91
+ p = _write(d, "k.py", 'api_key = "supersecretvalue"\n')
92
+ findings = detect_security_issues(p)
93
+ rules = [f["rule"] for f in findings]
94
+ assert "hardcoded-secret" in rules
95
+
96
+
97
+ def test_no_false_positive_on_clean_file() -> None:
98
+ with tempfile.TemporaryDirectory() as d:
99
+ p = _write(d, "clean.py", "def add(a, b):\n return a + b\n")
100
+ findings = detect_security_issues(p)
101
+ assert findings == []
102
+
103
+
104
+ def test_handles_syntax_error_gracefully() -> None:
105
+ with tempfile.TemporaryDirectory() as d:
106
+ p = _write(d, "bad.py", "def broken(\n")
107
+ findings = detect_security_issues(p)
108
+ assert findings == [] # returns empty, doesn't raise
109
+
110
+
111
+ def test_scan_repository_security_aggregates() -> None:
112
+ with tempfile.TemporaryDirectory() as d:
113
+ _write(d, "a.py", "eval(x)\n")
114
+ _write(d, "b.py", "def safe():\n return 1\n")
115
+ findings = scan_repository_security(d)
116
+ assert any(f["rule"] == "dangerous-eval" for f in findings)
117
+
118
+
119
+ @pytest.mark.parametrize("rule,code", [
120
+ ("dangerous-eval", "eval(x)\n"),
121
+ ("dangerous-exec", "exec(x)\n"),
122
+ ("shell-injection", "import subprocess\nsubprocess.run(c, shell=True)\n"),
123
+ ("hardcoded-secret", 'password = "hunter2abc"\n'),
124
+ ])
125
+ def test_parametrized_detection(rule: str, code: str) -> None:
126
+ with tempfile.TemporaryDirectory() as d:
127
+ p = _write(d, "t.py", code)
128
+ findings = detect_security_issues(p)
129
+ assert any(f["rule"] == rule for f in findings)
tests/test_circuit_breaker.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from app.core.circuit_breaker import CircuitBreaker, State
3
+
4
+
5
+ def _breaker(threshold: int = 3) -> CircuitBreaker:
6
+ return CircuitBreaker(failure_threshold=threshold, recovery_timeout=9999.0)
7
+
8
+
9
+ def test_initial_state_is_closed() -> None:
10
+ assert _breaker().state == State.CLOSED
11
+
12
+
13
+ def test_opens_after_threshold_failures() -> None:
14
+ b = _breaker(threshold=2)
15
+ b.record_failure()
16
+ assert b.state == State.CLOSED
17
+ b.record_failure()
18
+ assert b.state == State.OPEN
19
+
20
+
21
+ def test_is_open_returns_true_when_open() -> None:
22
+ b = _breaker(threshold=1)
23
+ b.record_failure()
24
+ assert b.is_open() is True
25
+
26
+
27
+ def test_success_resets_failures() -> None:
28
+ b = _breaker(threshold=3)
29
+ b.record_failure()
30
+ b.record_failure()
31
+ b.record_success()
32
+ assert b.state == State.CLOSED
33
+ assert b._failures == 0
34
+
35
+
36
+ def test_transitions_to_half_open_after_timeout() -> None:
37
+ import time
38
+ b = CircuitBreaker(failure_threshold=1, recovery_timeout=0.01)
39
+ b.record_failure()
40
+ assert b.state == State.OPEN
41
+ time.sleep(0.02)
42
+ assert b.state == State.HALF_OPEN
43
+
44
+
45
+ def test_reset_clears_to_closed() -> None:
46
+ b = _breaker(threshold=1)
47
+ b.record_failure()
48
+ b.reset()
49
+ assert b.state == State.CLOSED
50
+ assert b._failures == 0
51
+
52
+
53
+ def test_open_breaker_raises_in_call_llm() -> None:
54
+ """call_llm must raise RuntimeError immediately when breaker is open."""
55
+ import pytest
56
+ import asyncio
57
+ from unittest.mock import patch
58
+ from app.core.circuit_breaker import llm_breaker
59
+ from app.core.llm import call_llm
60
+
61
+ llm_breaker.reset()
62
+ for _ in range(5):
63
+ llm_breaker.record_failure()
64
+
65
+ assert llm_breaker.is_open()
66
+
67
+ with pytest.raises(RuntimeError, match="circuit breaker"):
68
+ asyncio.get_event_loop().run_until_complete(call_llm("test"))
69
+
70
+ llm_breaker.reset()