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 @@

Project Health Score

`; } + function closeSuppressDialog() { + const modal = document.getElementById('suppressDialog'); + if (!modal) return; + modal.remove(); + document.body.classList.remove('modal-open'); + document.removeEventListener('keydown', handleSuppressDialogEscape); + } + + function handleSuppressDialogEscape(event) { + if (event.key === 'Escape') { + closeSuppressDialog(); + } + } + + function removeSuppressedIssue(issueType, issueLine) { + if (!currentResult?.debugging?.issues) return; + + const normalizedIssueLine = issueLine ? Number(issueLine) : null; + const nextIssues = currentResult.debugging.issues.filter((issue) => { + const sameType = issue.type === issueType; + const sameLine = issue.line == null ? normalizedIssueLine == null : Number(issue.line) === normalizedIssueLine; + return !(sameType && sameLine); + }); + + const updatedDebugging = { + ...currentResult.debugging, + issues: nextIssues, + clean: nextIssues.length === 0, + error_count: nextIssues.filter(issue => issue.severity === 'error').length, + warning_count: nextIssues.filter(issue => issue.severity === 'warning').length, + info_count: nextIssues.filter(issue => issue.severity === 'info').length, + summary: nextIssues.length + ? `Found ${nextIssues.length} issue(s): ${nextIssues.filter(issue => issue.severity === 'error').length} error(s), ${nextIssues.filter(issue => issue.severity === 'warning').length} warning(s), ${nextIssues.filter(issue => issue.severity === 'info').length} info.` + : '✅ No issues detected!', + }; + + currentResult = { + ...currentResult, + debugging: updatedDebugging, + }; + + renderDebug(updatedDebugging); + } + + function openSuppressDialog(issueType, issueLine) { + closeSuppressDialog(); + + const modal = document.createElement('div'); + modal.id = 'suppressDialog'; + modal.className = 'suppress-dialog-backdrop'; + modal.setAttribute('role', 'presentation'); + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + document.body.classList.add('modal-open'); + document.addEventListener('keydown', handleSuppressDialogEscape); + + const reasonInput = modal.querySelector('#suppressReason'); + const submitButton = modal.querySelector('.suppress-submit'); + const errorBox = modal.querySelector('#suppressError'); + + const updateSubmitState = () => { + submitButton.disabled = !reasonInput.value.trim(); + }; + + reasonInput.addEventListener('input', updateSubmitState); + + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeSuppressDialog(); + } + }); + + modal.querySelector('.suppress-dialog-close').addEventListener('click', closeSuppressDialog); + modal.querySelector('.suppress-cancel').addEventListener('click', closeSuppressDialog); + modal.querySelector('.suppress-submit').addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + + const scopeInput = modal.querySelector('input[name="suppressScope"]:checked'); + const reason = reasonInput.value.trim(); + + if (!reason) { + reasonInput.focus(); + errorBox.textContent = 'Please enter a reason before suppressing.'; + errorBox.style.display = 'flex'; + return; + } + + submitButton.disabled = true; + submitButton.textContent = 'Suppressing…'; + errorBox.style.display = 'none'; + + try { + const base = getApiBase(); + const response = await fetchWithApiFallback(base, '/debugging/suppress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issue_type: issueType, + line: issueLine || null, + reason, + scope: scopeInput ? scopeInput.value : 'project' + }), + signal: AbortSignal.timeout(30000), + }); + + if (!response.response.ok) { + const err = await response.response.json().catch(() => ({})); + throw new Error(err.detail || err.message || `HTTP ${response.response.status}`); + } + + toast('Issue suppressed successfully', 'success'); + removeSuppressedIssue(issueType, issueLine); + closeSuppressDialog(); + } catch (error) { + errorBox.textContent = error.message || 'Unable to suppress issue.'; + errorBox.style.display = 'flex'; + submitButton.disabled = false; + submitButton.textContent = 'Suppress'; + } + }); + + updateSubmitState(); // ── Issue Complexity Badge ── // Pure frontend heuristic: scores an issue using fields already present on the // issue object (severity, description length, suggestion presence, keywords) @@ -4376,6 +4675,15 @@

Project Health Score

` : ''}
${issue.suggestion}
+ +
+ +