From a155a6a8c61e101319122367d20e60843ee0d569 Mon Sep 17 00:00:00 2001 From: Komal2008 Date: Sat, 18 Jul 2026 17:31:09 +0530 Subject: [PATCH 1/3] feat: add suppression modal UI --- frontend/index.html | 238 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/frontend/index.html b/frontend/index.html index fa307b2d..bcfc9ff5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1186,6 +1186,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; @@ -4066,6 +4209,93 @@

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 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); + + 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', () => { + const reasonInput = modal.querySelector('#suppressReason'); + const scopeInput = modal.querySelector('input[name="suppressScope"]:checked'); + + if (!reasonInput.value.trim()) { + reasonInput.focus(); + reasonInput.reportValidity(); + return; + } + + console.log({ + issue_type: issueType, + line: issueLine || 0, + reason: reasonInput.value.trim(), + scope: scopeInput ? scopeInput.value : 'project' + }); + + closeSuppressDialog(); + }); + } + function renderDebug(dbg) { document.getElementById('emptyDebug').style.display = 'none'; const el = document.getElementById('debugResult'); @@ -4123,6 +4353,14 @@

Project Health Score

: ''}
${issue.suggestion}
+ +
+ +
From 694cd8bb744454d6a58d96cf6dd7eec36e435cb8 Mon Sep 17 00:00:00 2001 From: Komal2008 Date: Mon, 20 Jul 2026 02:42:28 +0530 Subject: [PATCH 2/3] feat(debugging): add finding suppression support --- backend/app/models.py | 13 +++ backend/app/routers/debugging.py | 113 +++++++++++++++++++- backend/tests/test_endpoints.py | 164 +++++++++++++++++++++++++++++ backend/tests/test_schema_smoke.py | 24 +++++ docs/CHANGELOG.md | 6 +- frontend/index.html | 100 +++++++++++++++--- 6 files changed, 403 insertions(+), 17 deletions(-) diff --git a/backend/app/models.py b/backend/app/models.py index 70b94609..92204dcc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -109,3 +109,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..9e54378d 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -8,11 +8,16 @@ 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) @@ -27,6 +32,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 = """ Project Health Score } } + 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(); @@ -4255,10 +4285,11 @@

Suppress issue

+
- +
`; @@ -4267,6 +4298,16 @@

Suppress issue

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(); @@ -4275,25 +4316,55 @@

Suppress issue

modal.querySelector('.suppress-dialog-close').addEventListener('click', closeSuppressDialog); modal.querySelector('.suppress-cancel').addEventListener('click', closeSuppressDialog); - modal.querySelector('.suppress-submit').addEventListener('click', () => { - const reasonInput = modal.querySelector('#suppressReason'); + 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 (!reasonInput.value.trim()) { + if (!reason) { reasonInput.focus(); - reasonInput.reportValidity(); + errorBox.textContent = 'Please enter a reason before suppressing.'; + errorBox.style.display = 'flex'; return; } - console.log({ - issue_type: issueType, - line: issueLine || 0, - reason: reasonInput.value.trim(), - scope: scopeInput ? scopeInput.value : 'project' - }); + submitButton.disabled = true; + submitButton.textContent = 'Suppressing…'; + errorBox.style.display = 'none'; - closeSuppressDialog(); + 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(); } function renderDebug(dbg) { @@ -4356,8 +4427,9 @@

Suppress issue

@@ -6114,4 +6186,4 @@

Live Collaboration

- \ No newline at end of file + From 74768fb6e0b3df3a41005408ad9be127c9f6d7b9 Mon Sep 17 00:00:00 2001 From: Komal2008 Date: Sun, 26 Jul 2026 12:53:50 +0530 Subject: [PATCH 3/3] style: format test_endpoints with black --- backend/tests/test_endpoints.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 9e54378d..3682b9b9 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -23,8 +23,11 @@ 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() @@ -458,7 +461,7 @@ def test_debug_filters_suppressed_findings(isolated_debug_client): r = test_client.post( "/debugging/", json={ - "code": "result = a / b\npassword = \"abc123\"", + "code": 'result = a / b\npassword = "abc123"', "language": "python", }, ) @@ -645,7 +648,6 @@ def test_debug_kotlin(): assert d is not None - def test_debug_cpp_syntax_errors(): code = "void main() {\n cout << 'Hello World'\n}" r = client.post("/debugging/", json={"code": code, "language": "cpp"}) @@ -726,15 +728,20 @@ def test_add(): d = r.json() assert d["overall_score"] >= 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 @@ -888,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")