diff --git a/backend/app/models.py b/backend/app/models.py index 839d50eb..e2bc0a8e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -141,3 +141,16 @@ class AuditLog(Base): created_at: Mapped[datetime] = mapped_column( DateTime, default=lambda: datetime.now(UTC), index=True ) + + +class Suppression(Base): + __tablename__ = "suppressions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + issue_type: Mapped[str] = mapped_column(String(200), index=True) + line: Mapped[int] = mapped_column(Integer, nullable=True) + reason: Mapped[str] = mapped_column(Text) + scope: Mapped[str] = mapped_column(String(20), default="project", index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(UTC), index=True + ) diff --git a/backend/app/routers/debugging.py b/backend/app/routers/debugging.py index f475df73..375eb7f9 100644 --- a/backend/app/routers/debugging.py +++ b/backend/app/routers/debugging.py @@ -1,12 +1,78 @@ """Debugging router — POST /debugging/""" -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from ..database import Base, get_db +from ..models import Suppression from ..schemas import CodeRequest, DebuggingResponse from ..services.code_assistant import detect_language, run_bug_detection router = APIRouter() +ACTIVE_SUPPRESSION_SCOPES = {"project", "global"} + + +class SuppressionRequest(BaseModel): + issue_type: str = Field( + ..., min_length=1, max_length=200, description="Issue type to suppress" + ) + line: int | None = Field( + default=None, gt=0, description="Issue line number if known" + ) + reason: str = Field(..., min_length=1, description="Reason for the suppression") + scope: str = Field(default="project", description="Suppression scope") + + @field_validator("issue_type") + @classmethod + def validate_issue_type(cls, value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("issue_type must not be empty") + return stripped + + @field_validator("reason") + @classmethod + def validate_reason(cls, value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("reason must not be empty") + return stripped + + @field_validator("scope") + @classmethod + def validate_scope(cls, value: str) -> str: + normalized = value.strip().lower() + if normalized not in {"project", "global"}: + raise ValueError("scope must be 'project' or 'global'") + return normalized + + +def _suppression_matches(issue: dict, suppression: Suppression) -> bool: + if suppression.scope not in ACTIVE_SUPPRESSION_SCOPES: + return False + if issue.get("type") != suppression.issue_type: + return False + return suppression.line is None or issue.get("line") == suppression.line + + +def _filter_suppressed_issues( + issues: list[dict], suppressions: list[Suppression] +) -> list[dict]: + if not issues or not suppressions: + return issues + + return [ + issue + for issue in issues + if not any( + _suppression_matches(issue, suppression) for suppression in suppressions + ) + ] + @router.post( "/", @@ -34,9 +100,21 @@ 500: {"description": "Internal server error."}, }, ) -async def debug(req: CodeRequest): +async def debug(req: CodeRequest, db: Session = Depends(get_db)): + Base.metadata.create_all(bind=db.get_bind()) + lang = detect_language(req.code, req.language) issues = run_bug_detection(req.code, lang) + + try: + suppressions = db.execute(select(Suppression)).scalars().all() + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not load suppressions", + ) from exc + + issues = _filter_suppressed_issues(issues, list(suppressions)) errors = sum(1 for i in issues if i["severity"] == "error") warnings = sum(1 for i in issues if i["severity"] == "warning") infos = sum(1 for i in issues if i["severity"] == "info") @@ -53,3 +131,34 @@ async def debug(req: CodeRequest): "info_count": infos, "code": req.code, } + + +@router.post("/suppress", summary="Suppress a debug finding") +async def suppress_issue(req: SuppressionRequest, db: Session = Depends(get_db)): + Base.metadata.create_all(bind=db.get_bind()) + + suppression = Suppression( + issue_type=req.issue_type, + line=req.line, + reason=req.reason, + scope=req.scope, + ) + + try: + db.add(suppression) + db.commit() + db.refresh(suppression) + except SQLAlchemyError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not persist suppression", + ) from exc + + return { + "success": True, + "message": "Issue suppressed", + "issue_type": req.issue_type, + "line": req.line, + "scope": req.scope, + } diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 818eedff..3682b9b9 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -8,18 +8,26 @@ import pytest from pathlib import Path from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool import sys import os sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from app import main as app_main +from app.database import Base, get_db +from app.models import Suppression client = TestClient(app_main.app) FIXTURES_DIR = Path(__file__).parent / "fixtures" + def load_fixture(filename: str) -> str: return (FIXTURES_DIR / filename).read_text(encoding="utf-8") + + @pytest.fixture(autouse=True) def reset_rate_limit_state(): app_main._request_counts.clear() @@ -27,6 +35,38 @@ def reset_rate_limit_state(): app_main._request_counts.clear() +@pytest.fixture +def isolated_debug_client(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + + def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + previous_override = app_main.app.dependency_overrides.get(get_db) + app_main.app.dependency_overrides[get_db] = override_get_db + + try: + with TestClient(app_main.app) as test_client: + yield test_client, TestingSessionLocal + finally: + if previous_override is None: + app_main.app.dependency_overrides.pop(get_db, None) + else: + app_main.app.dependency_overrides[get_db] = previous_override + Base.metadata.drop_all(bind=engine) + engine.dispose() + + # ── Fixtures ────────────────────────────────────────────────────────────────── PHP_CODE = """ = 60 # clean code should score reasonably + def test_suggestions_observability_print_only_python(): # Pasting code with print() in Java should NOT trigger the Observability suggestion - r_java = client.post("/suggestions/", json={"code": 'print("hello");', "language": "java"}) + r_java = client.post( + "/suggestions/", json={"code": 'print("hello");', "language": "java"} + ) assert r_java.status_code == 200 s_java = [s["category"] for s in r_java.json()["suggestions"]] assert "Observability" not in s_java # Pasting code with print() in Python SHOULD trigger the Observability suggestion - r_py = client.post("/suggestions/", json={"code": 'print("hello")', "language": "python"}) + r_py = client.post( + "/suggestions/", json={"code": 'print("hello")', "language": "python"} + ) assert r_py.status_code == 200 s_py = [s["category"] for s in r_py.json()["suggestions"]] assert "Observability" in s_py @@ -724,7 +895,9 @@ def test_get_stream_done_event_present(): def test_get_stream_with_language_hint(): - r = client.get("/analyze/stream", params={"code": JS_CODE, "language": "javascript"}) + r = client.get( + "/analyze/stream", params={"code": JS_CODE, "language": "javascript"} + ) assert r.status_code == 200 events = _parse_sse_events(r.text) exp = next(e["data"] for e in events if e["type"] == "explanation") diff --git a/backend/tests/test_schema_smoke.py b/backend/tests/test_schema_smoke.py index 2884e23e..a0ca0167 100644 --- a/backend/tests/test_schema_smoke.py +++ b/backend/tests/test_schema_smoke.py @@ -42,6 +42,7 @@ FavoriteResult, QueryHistory, SharedSnippet, + Suppression, User, ) @@ -52,6 +53,7 @@ "favorite_results", "digest_subscriptions", "shares", + "suppressions", } # ── database URL (PostgreSQL in CI, SQLite locally) ─────────────────────────── @@ -230,6 +232,28 @@ def test_digest_subscription_insert(db_session): assert fetched.is_active is True +def test_suppression_insert(db_session): + """Suppression must accept a write and preserve the finding details.""" + suppression = Suppression( + issue_type="ZeroDivisionError", + line=1, + reason="False positive in generated sample", + scope="project", + ) + db_session.add(suppression) + db_session.commit() + db_session.refresh(suppression) + + fetched = ( + db_session.query(Suppression).filter_by(issue_type="ZeroDivisionError").first() + ) + assert fetched is not None + assert fetched.line == 1 + assert fetched.reason == "False positive in generated sample" + assert fetched.scope == "project" + assert fetched.created_at is not None + + # ── history DB (aiosqlite / FTS5) tests ─────────────────────────────────────── def test_history_db_init(): """init_db() must create the history and fts_history tables without error.""" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1525033e..f4f273a0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ All notable changes to QyverixAI are documented in this file. queryable `GET /admin/audit-logs` endpoint and admin-gated user role management (`PUT /admin/users/{id}/role`) and deletion (`DELETE /admin/users/{id}`). +- Added finding suppression support for debugging results. +- Added frontend integration for suppressing individual findings. +- Added backend persistence and API support for suppressed findings. +- Added automated tests for the finding suppression workflow. ### Changed - Linked the changelog from `README.md` for faster discoverability. @@ -34,7 +38,7 @@ All notable changes to QyverixAI are documented in this file. - Documentation and contribution guidance for GSSoC 2026 contributors. ### Fixed -- N/A +- Updated the debugging interface after suppressing findings to keep the active results synchronized. ### Security - N/A diff --git a/frontend/index.html b/frontend/index.html index 9a18c400..a013cbb0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1245,6 +1245,149 @@ margin-top: 3px } + .issue-actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; + } + + .suppress-btn { + border: 1px solid var(--border); + background: var(--bg); + color: var(--text2); + border-radius: 999px; + padding: 6px 12px; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + } + + .suppress-btn:hover { + background: var(--bg2); + color: var(--text1); + border-color: var(--accent); + } + + .suppress-dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(4, 10, 24, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 2000; + } + + .suppress-dialog { + width: min(100%, 480px); + background: var(--bg3); + border: 1px solid var(--border); + border-radius: var(--r2); + box-shadow: 0 18px 45px rgba(0, 0, 0, 0.28); + color: var(--text1); + overflow: hidden; + } + + .suppress-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + } + + .suppress-dialog-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 700; + } + + .suppress-dialog-close { + border: none; + background: transparent; + color: var(--text2); + font-size: 1.2rem; + cursor: pointer; + } + + .suppress-dialog-body { + padding: 16px 18px 8px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .suppress-dialog-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text2); + font-size: 0.9rem; + } + + .suppress-dialog-row strong { + color: var(--text1); + font-weight: 600; + } + + .suppress-field { + font-size: 0.85rem; + font-weight: 600; + color: var(--text2); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .suppress-field span { + font-size: 0.74rem; + color: var(--text3); + font-weight: 500; + } + + .suppress-textarea { + width: 100%; + min-height: 92px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: var(--bg); + color: var(--text1); + resize: vertical; + font: inherit; + } + + .suppress-scope { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text2); + font-size: 0.85rem; + } + + .suppress-scope label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text1); + } + + .suppress-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px 18px; + } + + .suppress-dialog-actions .btn { + min-width: 100px; + } + /* ── Explanation Cards ── */ .explain-summary { padding: 16px; @@ -4268,6 +4411,162 @@