Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 89 additions & 16 deletions backend/app/routers/analyze.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Full analysis router - POST /analyze/, /analyze/stream/, GET /analyze/stream, and /analyze/zip/."""

from __future__ import annotations

import asyncio
Expand All @@ -11,7 +12,16 @@
from fastapi import APIRouter, File, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import StreamingResponse

from ..schemas import AnalyzeResponse, CodeRequest, ZipAnalyzeResponse
from ..schemas import (
AnalyzeResponse,
CodeRequest,
IncrementalAnalyzeRequest,
IncrementalAnalyzeResponse,
ZipAnalyzeResponse,
)

from ..services.incremental_analysis import build_incremental_plan

from ..services.cache import cache
from ..services.code_assistant import (
detect_language,
Expand Down Expand Up @@ -137,11 +147,7 @@ def _safe_zip_name(name: str) -> str:
def _is_safe_member(name: str) -> bool:
path = PurePosixPath(name.replace("\\", "/"))
has_drive = bool(path.parts and path.parts[0].endswith(":"))
return (
not path.is_absolute()
and ".." not in path.parts
and not has_drive
)
return not path.is_absolute() and ".." not in path.parts and not has_drive


def _is_ignored_member(name: str) -> bool:
Expand Down Expand Up @@ -173,11 +179,15 @@ async def analyze_stream(req: CodeRequest):
response_class=StreamingResponse,
)
async def analyze_stream_get(
code: str = Query(..., min_length=1, max_length=50000, description="Source code to analyze"),
code: str = Query(
..., min_length=1, max_length=50000, description="Source code to analyze"
),
language: str | None = Query(None, description="Optional language hint"),
):
if not code.strip():
raise HTTPException(status_code=400, detail="code must not be empty or whitespace")
raise HTTPException(
status_code=400, detail="code must not be empty or whitespace"
)
return StreamingResponse(
_stream_analysis(code.strip(), language),
media_type="text/event-stream",
Expand Down Expand Up @@ -206,6 +216,75 @@ async def analyze(req: CodeRequest, response: Response):
return payload


@router.post(
"/incremental/",
response_model=IncrementalAnalyzeResponse,
summary="Run incremental analysis for changed files only",
)
async def analyze_incremental(req: IncrementalAnalyzeRequest):
"""Analyze only changed files or changed hunks from a previous version."""
t0 = time.perf_counter()

plans = build_incremental_plan(req.files)

results: list[dict] = []
analyzed_count = 0

for plan in plans:
if plan.skipped_reason or not plan.analysis_code:
results.append(
{
"filename": plan.path,
"previous_filename": plan.previous_path,
"status": plan.status,
"language": plan.language,
"changed_line_ranges": plan.changed_line_ranges,
"changed_line_count": plan.changed_line_count,
"size_bytes": len(plan.content.encode("utf-8"))
if plan.content
else 0,
"analysis": None,
"skipped_reason": plan.skipped_reason,
}
)
continue

analysis = full_analysis(plan.analysis_code, plan.language)
analyzed_count += 1

results.append(
{
"filename": plan.path,
"previous_filename": plan.previous_path,
"status": plan.status,
"language": analysis["explanation"]["language"],
"changed_line_ranges": plan.changed_line_ranges,
"changed_line_count": plan.changed_line_count,
"size_bytes": len(plan.content.encode("utf-8")) if plan.content else 0,
"analysis": analysis,
"skipped_reason": None,
}
)

elapsed_ms = round((time.perf_counter() - t0) * 1000, 2)
skipped_count = len(results) - analyzed_count

return {
"provider": "rule-based",
"model": "qyverix-engine-v3",
"file_count": len(results),
"analyzed_file_count": analyzed_count,
"skipped_file_count": skipped_count,
"files": results,
"summary": (
f"Incremental analysis completed. "
f"Analyzed {analyzed_count} changed file(s), "
f"skipped {skipped_count} unchanged/deleted file(s)."
),
"analysis_time_ms": elapsed_ms,
}


@router.post(
"/zip/",
response_model=ZipAnalyzeResponse,
Expand Down Expand Up @@ -266,10 +345,7 @@ async def analyze_zip(request: Request, file: UploadFile = File(...)):
total_size = 0

with archive:
members = [
info for info in archive.infolist()
if not info.is_dir()
]
members = [info for info in archive.infolist() if not info.is_dir()]

if not members:
raise HTTPException(
Expand Down Expand Up @@ -352,10 +428,7 @@ async def analyze_zip(request: Request, file: UploadFile = File(...)):
detail="ZIP file does not contain readable source files",
)

scores = [
item["analysis"]["suggestions"]["overall_score"]
for item in results
]
scores = [item["analysis"]["suggestions"]["overall_score"] for item in results]

overall_score = round(sum(scores) / len(scores))

Expand Down
67 changes: 66 additions & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations
import json
from typing import Any
from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator, model_validator

Expand All @@ -16,6 +16,7 @@
validate_stored_result_json,
)


class CodeRequest(BaseModel):
code: str
language: str | None = None
Expand Down Expand Up @@ -95,6 +96,70 @@ class AnalyzeResponse(BaseModel):
analysis_time_ms: float | None = None


class IncrementalFileChange(BaseModel):
path: str = Field(..., min_length=1, max_length=500)
content: str | None = Field(default=None, max_length=50_000)
previous_path: str | None = Field(default=None, max_length=500)
previous_content: str | None = Field(default=None, max_length=50_000)
language: str | None = None
status: Literal["added", "modified", "renamed", "deleted"] | None = None

@field_validator("path", "previous_path")
@classmethod
def validate_path(cls, value: str | None) -> str | None:
if value is None:
return value

cleaned = value.replace("\\", "/").strip()

if not cleaned:
raise ValueError("path must not be empty")

if cleaned.startswith("/") or ".." in cleaned.split("/"):
raise ValueError("path must be a safe relative path")

return cleaned

@field_validator("content", "previous_content")
@classmethod
def sanitize_file_content(cls, value: str | None) -> str | None:
if value is None:
return value
return sanitize_code_input(value)

@field_validator("language")
@classmethod
def sanitize_incremental_language(cls, value: str | None) -> str | None:
return validate_language_hint(value)


class IncrementalAnalyzeRequest(BaseModel):
files: list[IncrementalFileChange] = Field(..., min_length=1, max_length=50)


class IncrementalAnalyzeFileResult(BaseModel):
filename: str
previous_filename: str | None = None
status: str
language: str | None = None
changed_line_ranges: list[list[int]] = Field(default_factory=list)
changed_line_count: int = 0
size_bytes: int = 0
analysis: AnalyzeResponse | None = None
skipped_reason: str | None = None


class IncrementalAnalyzeResponse(BaseModel):
provider: str
model: str
file_count: int
analyzed_file_count: int
skipped_file_count: int
files: list[IncrementalAnalyzeFileResult]
summary: str
analysis_time_ms: float | None = None


class ZipAnalyzeFileResult(BaseModel):
filename: str
language: str
Expand Down
Loading