Skip to content
Closed
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
18 changes: 0 additions & 18 deletions .env

This file was deleted.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ backend/app/frontend/
/playwright-report/
/blob-report/
/playwright/.cache/
.env
*.db
9 changes: 4 additions & 5 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import warnings
from pathlib import Path
from typing import Literal, Self

from pydantic import (
Expand All @@ -14,11 +15,11 @@

class Settings(BaseSettings):
model_config = SettingsConfigDict(
# Use top level .env file (one level above ./backend/)
env_file="../.env",
env_file=Path(__file__).resolve().parents[3] / ".env",
env_ignore_empty=True,
extra="ignore",
)

API_V1_STR: str = "/api/v1"
SECRET_KEY: str
# 60 minutes * 24 hours * 8 days = 8 days
Expand All @@ -28,7 +29,7 @@ class Settings(BaseSettings):

PROJECT_NAME: str
SENTRY_DSN: HttpUrl | None = None
DATABASE_URL: PostgresDsn
DATABASE_URL: str

@field_validator("DATABASE_URL", mode="before")
@classmethod
Expand Down Expand Up @@ -79,8 +80,6 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None:
@model_validator(mode="after")
def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
for host in self.DATABASE_URL.hosts():
self._check_default_secret("DATABASE_URL password", host["password"])
self._check_default_secret(
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
)
Expand Down
7 changes: 2 additions & 5 deletions backend/app/core/db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from sqlmodel import Session, create_engine, select
from sqlmodel import Session, SQLModel, create_engine, select

from app import crud
from app.core.config import settings
Expand All @@ -16,10 +16,7 @@ def init_db(session: Session) -> None:
# Tables should be created with Alembic migrations
# But if you don't want to use migrations, create
# the tables un-commenting the next lines
# from sqlmodel import SQLModel

# This works because the models are already imported and registered from app.models
# SQLModel.metadata.create_all(engine)
SQLModel.metadata.create_all(engine)

user = session.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
Expand Down
6 changes: 6 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

from app.api.main import api_router
from app.core.config import settings
from app.routes import reports

# ...


FRONTEND_DIR = Path(__file__).parent / "frontend"

Expand All @@ -24,6 +28,8 @@ def custom_generate_unique_id(route: APIRoute) -> str:
generate_unique_id_function=custom_generate_unique_id,
)

app.include_router(reports.router)

app.add_middleware(
CORSMiddleware,
allow_origins=[settings.FRONTEND_HOST],
Expand Down
37 changes: 37 additions & 0 deletions backend/app/routes/reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Serves generated diff/impact reports to the frontend.
Mount path: backend/app/routes/reports.py
"""

import json
from pathlib import Path

from fastapi import APIRouter, HTTPException

router = APIRouter(prefix="/api/reports", tags=["reports"])
REPORTS_DIR = Path("contractguard/reports")


@router.get("/")
def list_reports():
"""Summary list for the report viewer's landing page."""
reports = []
for file in sorted(REPORTS_DIR.glob("impact_report_*.json")):
data = json.loads(file.read_text())
reports.append(
{
"change_id": data.get("change_id"),
"severity": data.get("severity"),
"verify_status": data.get("verify_status"),
}
)
return reports


@router.get("/{change_id}")
def get_report(change_id: str):
"""Full detail for one scenario, e.g. GET /api/reports/field_renamed"""
file = REPORTS_DIR / f"impact_report_{change_id}.json"
if not file.exists():
raise HTTPException(status_code=404, detail="Report not found")
return json.loads(file.read_text())
Empty file.
225 changes: 225 additions & 0 deletions backend/contractguard/diff_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"""
Diff Agent — Mahi's core deliverable.

Usage:
python diff_agent.py <old_openapi.json> <new_openapi.json> <output_diff_report.json>
"""

import json
import sys
from datetime import UTC, datetime
from pathlib import Path


def load_spec(path: str) -> dict:
return json.loads(Path(path).read_text())


def _resolve_ref(obj: dict) -> str | None:
if not isinstance(obj, dict):
return None
if "$ref" in obj:
return obj["$ref"].split("/")[-1]
if "items" in obj and isinstance(obj["items"], dict) and "$ref" in obj["items"]:
return obj["items"]["$ref"].split("/")[-1]
return None


def _extract_type(field_def: dict) -> str:
if not isinstance(field_def, dict):
return "unknown"
if "type" in field_def:
t = field_def["type"]
if isinstance(t, list):
types = [x for x in t if x != "null"]
return types[0] if types else "null"
return str(t)
if "anyOf" in field_def or "oneOf" in field_def:
variants = field_def.get("anyOf") or field_def.get("oneOf") or []
for v in variants:
if isinstance(v, dict):
if "$ref" in v:
return v["$ref"].split("/")[-1]
if "type" in v:
return str(v["type"])
if "$ref" in field_def:
return field_def["$ref"].split("/")[-1]
return "unknown"


def _extract_fields_from_schema(schema: dict, components: dict) -> dict[str, str]:
fields = {}
if not isinstance(schema, dict):
return fields

ref_name = _resolve_ref(schema)
if ref_name and ref_name in components:
schema = components[ref_name]

# Unwrap allOf / anyOf wrappers
if "allOf" in schema:
for sub in schema["allOf"]:
fields.update(_extract_fields_from_schema(sub, components))

props = schema.get("properties", {})
if isinstance(props, dict):
for fname, fdef in props.items():
fields[fname] = _extract_type(fdef)

return fields


def get_endpoint_schemas(spec: dict) -> dict:
result = {}
paths = spec.get("paths", {})
components = spec.get("components", {}).get("schemas", {})

for path, methods in paths.items():
if not isinstance(methods, dict):
continue
for method, details in methods.items():
m = method.upper()
if m not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
continue

key = f"{m} {path}"
fields = {}

# 1. Extract from Request Body (POST, PUT, PATCH)
req_body = details.get("requestBody", {})
if isinstance(req_body, dict):
content = req_body.get("content", {}).get("application/json", {})
req_schema = content.get("schema", {})
fields.update(_extract_fields_from_schema(req_schema, components))

# 2. Extract from Responses (200, 201, 204, or default)
responses = details.get("responses", {})
if isinstance(responses, dict):
res_obj = (
responses.get("200")
or responses.get("201")
or responses.get("204")
or responses.get("200 OK")
or {}
)
if isinstance(res_obj, dict):
content = res_obj.get("content", {}).get("application/json", {})
res_schema = content.get("schema", {})
fields.update(_extract_fields_from_schema(res_schema, components))

# Fallback if no explicit fields resolved
if not fields:
fields["$response"] = "void" if m == "DELETE" else "unknown"

result[key] = fields

return result


def diff_specs(old_spec: dict, new_spec: dict) -> list[dict]:
old_endpoints = get_endpoint_schemas(old_spec)
new_endpoints = get_endpoint_schemas(new_spec)
changes: list[dict] = []
now = datetime.now(UTC).isoformat()

def base_entry(
key: str,
change_type: str,
breaking: bool,
old_frag: dict,
new_frag: dict,
severity: str,
) -> dict:
method, endpoint = key.split(" ", 1)
return {
"endpoint": endpoint,
"method": method,
"change_type": change_type,
"breaking": breaking,
"old_schema_fragment": old_frag,
"new_schema_fragment": new_frag,
"severity": severity,
"detected_at": now,
}

for key, old_fields in old_endpoints.items():
if key not in new_endpoints:
changes.append(
base_entry(key, "endpoint_removed", True, old_fields, {}, "high")
)
continue

new_fields = new_endpoints[key]
removed = set(old_fields) - set(new_fields)
added = set(new_fields) - set(old_fields)
common = set(old_fields) & set(new_fields)

# Rename heuristic: removed key + added key with matching type
for r in list(removed):
match = next((a for a in added if old_fields[r] == new_fields[a]), None)
if match:
changes.append(
base_entry(
key,
"field_renamed",
True,
{r: old_fields[r]},
{match: new_fields[match]},
"high",
)
)
removed.discard(r)
added.discard(match)

for r in removed:
changes.append(
base_entry(key, "field_removed", True, {r: old_fields[r]}, {}, "medium")
)

for a in added:
changes.append(
base_entry(
key, "field_added_required", False, {}, {a: new_fields[a]}, "low"
)
)

for f in common:
if old_fields[f] != new_fields[f]:
changes.append(
base_entry(
key,
"field_type_changed",
True,
{f: old_fields[f]},
{f: new_fields[f]},
"high",
)
)

return changes


def main():
if len(sys.argv) != 4:
print(
"Usage: python diff_agent.py <old_openapi.json> <new_openapi.json> <output.json>"
)
sys.exit(1)

old_path, new_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
old_spec = load_spec(old_path)
new_spec = load_spec(new_path)

changes = diff_specs(old_spec, new_spec)

Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_text(json.dumps(changes, indent=2))
print(f"Wrote {len(changes)} change(s) to {out_path}")
for c in changes:
print(
f" - [{c['severity']}] {c['change_type']} on {c['method']} {c['endpoint']}"
)


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions backend/contractguard/reports/diff_report_endpoint_removed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"endpoint": "/api/v1/items/{id}",
"method": "DELETE",
"change_type": "endpoint_removed",
"breaking": true,
"old_schema_fragment": {
"message": "string"
},
"new_schema_fragment": {},
"severity": "high",
"detected_at": "2026-09-26T17:00:02.339502+00:00"
}
]
Loading
Loading