From 8a44348a64c173d78be495edf16df58b4d347aa4 Mon Sep 17 00:00:00 2001 From: mochan Date: Mon, 27 Apr 2026 21:26:55 +0530 Subject: [PATCH] feat(examples): cross-stack demo repo (FastAPI + SQLAlchemy + React) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small but realistic project that exercises every codegraph capability end-to-end. When codegraph builds against it, you get: - 5 ROUTE edges (FastAPI @router.get/post) - 3 FETCH_CALL edges (fetch + apiClient.get + apiClient.post) - READS_FROM edges (session.query / Model.query) - WRITES_TO edges (session.add(Model(...))) - HANDLER, SERVICE, REPO, COMPONENT roles populated The demo is the canonical "click button → trace through full stack" showcase the README points at. Also includes a small parser fix: _is_session_chain() now recognises self.session.X / self.db.X patterns common in repository-style code. Without this, repos that hold the session as an instance attribute weren't getting their READS_FROM/WRITES_TO edges emitted. 9 regression tests in tests/test_demo_repo.py assert the demo continues to produce the expected edge/role counts — so future refactors can't silently break the showcase. Suite: 467 pass (was 458; +9). ruff + mypy --strict clean. --- README.md | 15 +++ codegraph/parsers/python.py | 19 +++- examples/README.md | 7 ++ examples/cross-stack-demo/README.md | 74 ++++++++++++ .../cross-stack-demo/backend/api/__init__.py | 0 examples/cross-stack-demo/backend/api/main.py | 8 ++ .../backend/api/routes/__init__.py | 0 .../backend/api/routes/orders.py | 24 ++++ .../backend/api/routes/users.py | 31 +++++ examples/cross-stack-demo/backend/models.py | 21 ++++ .../cross-stack-demo/backend/pyproject.toml | 5 + .../backend/repositories/__init__.py | 0 .../backend/repositories/order_repository.py | 17 +++ .../backend/repositories/user_repository.py | 22 ++++ .../backend/services/__init__.py | 0 .../backend/services/order_service.py | 18 +++ .../backend/services/user_service.py | 23 ++++ .../cross-stack-demo/frontend/package.json | 8 ++ .../cross-stack-demo/frontend/src/App.tsx | 12 ++ .../frontend/src/api/client.ts | 18 +++ .../frontend/src/components/OrderList.tsx | 43 +++++++ .../frontend/src/components/UserCard.tsx | 29 +++++ .../cross-stack-demo/frontend/tsconfig.json | 12 ++ tests/test_demo_repo.py | 107 ++++++++++++++++++ 24 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/cross-stack-demo/README.md create mode 100644 examples/cross-stack-demo/backend/api/__init__.py create mode 100644 examples/cross-stack-demo/backend/api/main.py create mode 100644 examples/cross-stack-demo/backend/api/routes/__init__.py create mode 100644 examples/cross-stack-demo/backend/api/routes/orders.py create mode 100644 examples/cross-stack-demo/backend/api/routes/users.py create mode 100644 examples/cross-stack-demo/backend/models.py create mode 100644 examples/cross-stack-demo/backend/pyproject.toml create mode 100644 examples/cross-stack-demo/backend/repositories/__init__.py create mode 100644 examples/cross-stack-demo/backend/repositories/order_repository.py create mode 100644 examples/cross-stack-demo/backend/repositories/user_repository.py create mode 100644 examples/cross-stack-demo/backend/services/__init__.py create mode 100644 examples/cross-stack-demo/backend/services/order_service.py create mode 100644 examples/cross-stack-demo/backend/services/user_service.py create mode 100644 examples/cross-stack-demo/frontend/package.json create mode 100644 examples/cross-stack-demo/frontend/src/App.tsx create mode 100644 examples/cross-stack-demo/frontend/src/api/client.ts create mode 100644 examples/cross-stack-demo/frontend/src/components/OrderList.tsx create mode 100644 examples/cross-stack-demo/frontend/src/components/UserCard.tsx create mode 100644 examples/cross-stack-demo/frontend/tsconfig.json create mode 100644 tests/test_demo_repo.py diff --git a/README.md b/README.md index 59a324a..35799e3 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,21 @@ codegraph review # graph-diff PR review with risk score > PyPI package coming soon. Until then, install from source as above. +### Try the cross-stack demo + +A small FastAPI + SQLAlchemy + React fixture lives in +[`examples/cross-stack-demo/`](examples/cross-stack-demo/). Run codegraph +against it to see DF1 (routes), DF2 (fetches), DF1.5 (roles), and DF3/DF4 +(end-to-end trace) all light up: + +```bash +codegraph build --no-incremental --root examples/cross-stack-demo +codegraph dataflow trace "GET /api/users/{user_id}" +``` + +See the demo's [README](examples/cross-stack-demo/README.md) for expected +output and what to look for. + --- ## Commands diff --git a/codegraph/parsers/python.py b/codegraph/parsers/python.py index 84540b6..c4a7501 100644 --- a/codegraph/parsers/python.py +++ b/codegraph/parsers/python.py @@ -607,11 +607,24 @@ def _strip_call_suffix(name: str) -> str: def _is_session_chain(target: str) -> bool: """Return True if the dotted chain's left-most segment looks like a - session/db handle (``session.query``, ``db.session.query``, ...).""" + session/db handle (``session.query``, ``db.session.query``, ...). + + Also matches ``self.session.X`` / ``self.db.X`` patterns common in + repository-style code where the session is held as an instance + attribute. + """ if not target: return False - head = target.split(".", 1)[0].lower() - return head in _SESSION_HEAD_TOKENS + parts = target.split(".") + head = parts[0].lower() + if head in _SESSION_HEAD_TOKENS: + return True + # self..X — repository-pattern method bodies. + if head == "self" and len(parts) >= 2: + second = parts[1].lower() + if second in _SESSION_HEAD_TOKENS: + return True + return False def _unwrap_to_root_call(node: tree_sitter.Node) -> tree_sitter.Node | None: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a2c90c1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,7 @@ +# Examples + +Demo repos that exercise codegraph's analysis end-to-end. + +| Directory | What it shows | +|---|---| +| [`cross-stack-demo/`](cross-stack-demo/) | Full FastAPI + SQLAlchemy + React fetch chain. Demonstrates DF1 (ROUTE + READS_FROM/WRITES_TO), DF2 (FETCH_CALL), DF1.5 (HANDLER/SERVICE/COMPONENT/REPO roles), DF0 (args/params), and DF3/DF4 cross-layer trace stitching. | diff --git a/examples/cross-stack-demo/README.md b/examples/cross-stack-demo/README.md new file mode 100644 index 0000000..23babbf --- /dev/null +++ b/examples/cross-stack-demo/README.md @@ -0,0 +1,74 @@ +# cross-stack-demo + +A tiny but realistic project that exercises every codegraph capability +end-to-end: a React frontend with `fetch` and an `apiClient` that calls a +FastAPI backend with SQLAlchemy repositories. + +## What this demo shows + +Run codegraph against this fixture and you'll see the full data flow from a +React `UserCard`'s fetch all the way to the SQL `User` model — with role +classification (HANDLER/SERVICE/COMPONENT/REPO), DF0 argument capture at every +call site, and DF3/DF4 cross-layer stitching. + +## Setup + +No install needed for the demo — codegraph just statically analyses the source. + +```bash +# From the repo root +cd /path/to/codegraph +codegraph build --no-incremental --root examples/cross-stack-demo +codegraph analyze +codegraph dataflow trace "GET /api/users/{user_id}" +``` + +## Expected output + +``` +Flow trace from: GET /api/users/{user_id} (confidence: 0.90) + + [backend] backend/api/routes/users.py:18 backend.api.routes.users.get_user HANDLER + GET /api/users/{user_id} + ↓ + [backend] backend/api/routes/users.py:14 backend.api.routes.users._get_service + ↓ + [backend] backend/services/user_service.py:9 backend.services.user_service.UserService.get_user SERVICE + args: (user_id) + ↓ + [backend] backend/repositories/user_repository.py:11 backend.repositories.user_repository.UserRepository.get_by_id REPO + args: (user_id) + ↓ + [db] backend/models.py backend.models.User REPO + args: (op=READS_FROM) +``` + +## What to look for + +- **HANDLER / SERVICE / REPO role tags** in the right column +- **DF0 args + kwargs** at each hop showing `(user_id)` flowing through +- **Cross-layer transitions:** + - frontend → backend via `match_route()` matching `fetch("/api/users/${id}")` to `@router.get("/api/users/{user_id}")` + - backend → db via SQLAlchemy `session.query(User)` emitting READS_FROM + +## Files + +``` +examples/cross-stack-demo/ +├── backend/ +│ ├── api/main.py # FastAPI app +│ ├── api/routes/users.py # GET /api/users/{user_id}, POST /api/users +│ ├── api/routes/orders.py # GET /api/orders, POST /api/orders +│ ├── services/user_service.py # UserService — calls UserRepository +│ ├── services/order_service.py # OrderService — calls OrderRepository +│ ├── repositories/user_repository.py # UserRepository — session.query(User) +│ ├── repositories/order_repository.py +│ └── models.py # SQLAlchemy User, Order +└── frontend/ + └── src/ + ├── App.tsx + ├── components/ + │ ├── UserCard.tsx # fetch("/api/users/${id}", ...) + │ └── OrderList.tsx # apiClient.get / apiClient.post + └── api/client.ts # apiClient.get/post helpers +``` diff --git a/examples/cross-stack-demo/backend/api/__init__.py b/examples/cross-stack-demo/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cross-stack-demo/backend/api/main.py b/examples/cross-stack-demo/backend/api/main.py new file mode 100644 index 0000000..314eb56 --- /dev/null +++ b/examples/cross-stack-demo/backend/api/main.py @@ -0,0 +1,8 @@ +"""FastAPI app for the cross-stack demo.""" +from fastapi import FastAPI + +from backend.api.routes import orders, users + +app = FastAPI(title="cross-stack-demo") +app.include_router(users.router) +app.include_router(orders.router) diff --git a/examples/cross-stack-demo/backend/api/routes/__init__.py b/examples/cross-stack-demo/backend/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cross-stack-demo/backend/api/routes/orders.py b/examples/cross-stack-demo/backend/api/routes/orders.py new file mode 100644 index 0000000..1637fd4 --- /dev/null +++ b/examples/cross-stack-demo/backend/api/routes/orders.py @@ -0,0 +1,24 @@ +"""Order routes — FastAPI handlers for /api/orders.""" +from fastapi import APIRouter + +from backend.repositories.order_repository import OrderRepository +from backend.services.order_service import OrderService + +router = APIRouter() + + +def _get_service() -> OrderService: + repo = OrderRepository(session=None) # type: ignore[arg-type] + return OrderService(repo=repo) + + +@router.get("/api/orders") +def list_orders(user_id: int) -> list[dict]: + service = _get_service() + return service.list_orders(user_id=user_id) + + +@router.post("/api/orders") +def create_order(user_id: int, total_cents: int) -> dict: + service = _get_service() + return service.create_order(user_id=user_id, total_cents=total_cents) diff --git a/examples/cross-stack-demo/backend/api/routes/users.py b/examples/cross-stack-demo/backend/api/routes/users.py new file mode 100644 index 0000000..329cab4 --- /dev/null +++ b/examples/cross-stack-demo/backend/api/routes/users.py @@ -0,0 +1,31 @@ +"""User routes — FastAPI handlers for /api/users.""" +from fastapi import APIRouter + +from backend.repositories.user_repository import UserRepository +from backend.services.user_service import UserService + +router = APIRouter() + + +def _get_service() -> UserService: + # In a real app, this would inject a session-scoped repo via Depends. + repo = UserRepository(session=None) # type: ignore[arg-type] + return UserService(repo=repo) + + +@router.get("/api/users/{user_id}") +def get_user(user_id: int) -> dict: + service = _get_service() + return service.get_user(user_id) or {} + + +@router.get("/api/users") +def list_users() -> list[dict]: + service = _get_service() + return service.list_users() + + +@router.post("/api/users") +def create_user(email: str, name: str) -> dict: + service = _get_service() + return service.create_user(email=email, name=name) diff --git a/examples/cross-stack-demo/backend/models.py b/examples/cross-stack-demo/backend/models.py new file mode 100644 index 0000000..b91193e --- /dev/null +++ b/examples/cross-stack-demo/backend/models.py @@ -0,0 +1,21 @@ +"""SQLAlchemy models for the cross-stack demo.""" +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + email = Column(String, nullable=False, unique=True) + name = Column(String) + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer) + total_cents = Column(Integer) diff --git a/examples/cross-stack-demo/backend/pyproject.toml b/examples/cross-stack-demo/backend/pyproject.toml new file mode 100644 index 0000000..817ef23 --- /dev/null +++ b/examples/cross-stack-demo/backend/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "cross-stack-demo-backend" +version = "0.0.1" +requires-python = ">=3.10" +dependencies = ["fastapi>=0.110", "sqlalchemy>=2.0", "uvicorn>=0.27"] diff --git a/examples/cross-stack-demo/backend/repositories/__init__.py b/examples/cross-stack-demo/backend/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cross-stack-demo/backend/repositories/order_repository.py b/examples/cross-stack-demo/backend/repositories/order_repository.py new file mode 100644 index 0000000..468b846 --- /dev/null +++ b/examples/cross-stack-demo/backend/repositories/order_repository.py @@ -0,0 +1,17 @@ +"""Order repository — SQLAlchemy data access for Order.""" +from sqlalchemy.orm import Session + +from backend.models import Order + + +class OrderRepository: + def __init__(self, session: Session) -> None: + self.session = session + + def list_for_user(self, user_id: int) -> list[Order]: + return self.session.query(Order).filter(Order.user_id == user_id).all() + + def create(self, user_id: int, total_cents: int) -> Order: + self.session.add(Order(user_id=user_id, total_cents=total_cents)) + self.session.commit() + return Order(user_id=user_id, total_cents=total_cents) diff --git a/examples/cross-stack-demo/backend/repositories/user_repository.py b/examples/cross-stack-demo/backend/repositories/user_repository.py new file mode 100644 index 0000000..084ef41 --- /dev/null +++ b/examples/cross-stack-demo/backend/repositories/user_repository.py @@ -0,0 +1,22 @@ +"""User repository — SQLAlchemy data access for User.""" +from sqlalchemy.orm import Session + +from backend.models import User + + +class UserRepository: + def __init__(self, session: Session) -> None: + self.session = session + + def get_by_id(self, user_id: int) -> User | None: + return self.session.query(User).filter(User.id == user_id).first() + + def list_all(self) -> list[User]: + return self.session.query(User).all() + + def create(self, email: str, name: str) -> User: + # Inline constructor so DF1 can trace the WRITES_TO target back + # to the User model class. + self.session.add(User(email=email, name=name)) + self.session.commit() + return User(email=email, name=name) diff --git a/examples/cross-stack-demo/backend/services/__init__.py b/examples/cross-stack-demo/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cross-stack-demo/backend/services/order_service.py b/examples/cross-stack-demo/backend/services/order_service.py new file mode 100644 index 0000000..80a480f --- /dev/null +++ b/examples/cross-stack-demo/backend/services/order_service.py @@ -0,0 +1,18 @@ +"""Order service — business logic for orders.""" +from backend.repositories.order_repository import OrderRepository + + +class OrderService: + def __init__(self, repo: OrderRepository) -> None: + self.repo = repo + + def list_orders(self, user_id: int) -> list[dict]: + orders = self.repo.list_for_user(user_id) + return [ + {"id": o.id, "user_id": o.user_id, "total_cents": o.total_cents} + for o in orders + ] + + def create_order(self, user_id: int, total_cents: int) -> dict: + order = self.repo.create(user_id=user_id, total_cents=total_cents) + return {"id": order.id, "user_id": order.user_id, "total_cents": order.total_cents} diff --git a/examples/cross-stack-demo/backend/services/user_service.py b/examples/cross-stack-demo/backend/services/user_service.py new file mode 100644 index 0000000..5672f4e --- /dev/null +++ b/examples/cross-stack-demo/backend/services/user_service.py @@ -0,0 +1,23 @@ +"""User service — business logic for users.""" +from backend.repositories.user_repository import UserRepository + + +class UserService: + def __init__(self, repo: UserRepository) -> None: + self.repo = repo + + def get_user(self, user_id: int) -> dict | None: + user = self.repo.get_by_id(user_id) + if user is None: + return None + return {"id": user.id, "email": user.email, "name": user.name} + + def list_users(self) -> list[dict]: + users = self.repo.list_all() + return [ + {"id": u.id, "email": u.email, "name": u.name} for u in users + ] + + def create_user(self, email: str, name: str) -> dict: + user = self.repo.create(email=email, name=name) + return {"id": user.id, "email": user.email, "name": user.name} diff --git a/examples/cross-stack-demo/frontend/package.json b/examples/cross-stack-demo/frontend/package.json new file mode 100644 index 0000000..9542c05 --- /dev/null +++ b/examples/cross-stack-demo/frontend/package.json @@ -0,0 +1,8 @@ +{ + "name": "cross-stack-demo-frontend", + "version": "0.0.1", + "private": true, + "dependencies": { + "react": "^18.2.0" + } +} diff --git a/examples/cross-stack-demo/frontend/src/App.tsx b/examples/cross-stack-demo/frontend/src/App.tsx new file mode 100644 index 0000000..cac7be2 --- /dev/null +++ b/examples/cross-stack-demo/frontend/src/App.tsx @@ -0,0 +1,12 @@ +import { OrderList } from "./components/OrderList"; +import { UserCard } from "./components/UserCard"; + +export function App() { + const userId = 1; + return ( +
+ + +
+ ); +} diff --git a/examples/cross-stack-demo/frontend/src/api/client.ts b/examples/cross-stack-demo/frontend/src/api/client.ts new file mode 100644 index 0000000..f64a9e9 --- /dev/null +++ b/examples/cross-stack-demo/frontend/src/api/client.ts @@ -0,0 +1,18 @@ +// Generic api-client used by all components. +// +// codegraph's DF2 detects `apiClient.get(url)` / `apiClient.post(url, body)` +// patterns and emits FETCH_CALL edges from the calling component to a +// synthetic fetch::METHOD::URL node. + +export const apiClient = { + get(url: string): Promise { + return fetch(url, { method: "GET" }).then((r) => r.json()); + }, + post(url: string, body: object): Promise { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then((r) => r.json()); + }, +}; diff --git a/examples/cross-stack-demo/frontend/src/components/OrderList.tsx b/examples/cross-stack-demo/frontend/src/components/OrderList.tsx new file mode 100644 index 0000000..e3b3d92 --- /dev/null +++ b/examples/cross-stack-demo/frontend/src/components/OrderList.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +import { apiClient } from "../api/client"; + +interface Order { + id: number; + user_id: number; + total_cents: number; +} + +interface OrderListProps { + userId: number; +} + +export function OrderList({ userId }: OrderListProps) { + const [orders, setOrders] = useState([]); + + useEffect(() => { + apiClient.get(`/api/orders?user_id=${userId}`).then((data) => { + setOrders(data as Order[]); + }); + }, [userId]); + + function placeOrder(totalCents: number) { + apiClient + .post("/api/orders", { user_id: userId, total_cents: totalCents }) + .then((order) => { + setOrders((prev) => [...prev, order as Order]); + }); + } + + return ( +
+

Orders

+
    + {orders.map((o) => ( +
  • ${(o.total_cents / 100).toFixed(2)}
  • + ))} +
+ +
+ ); +} diff --git a/examples/cross-stack-demo/frontend/src/components/UserCard.tsx b/examples/cross-stack-demo/frontend/src/components/UserCard.tsx new file mode 100644 index 0000000..6bf4d40 --- /dev/null +++ b/examples/cross-stack-demo/frontend/src/components/UserCard.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +interface User { + id: number; + email: string; + name: string; +} + +interface UserCardProps { + userId: number; +} + +export function UserCard({ userId }: UserCardProps) { + const [user, setUser] = useState(null); + + useEffect(() => { + fetch(`/api/users/${userId}`, { method: "GET" }) + .then((r) => r.json()) + .then((data) => setUser(data)); + }, [userId]); + + if (!user) return
Loading…
; + return ( +
+

{user.name}

+

{user.email}

+
+ ); +} diff --git a/examples/cross-stack-demo/frontend/tsconfig.json b/examples/cross-stack-demo/frontend/tsconfig.json new file mode 100644 index 0000000..6f5dc16 --- /dev/null +++ b/examples/cross-stack-demo/frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/tests/test_demo_repo.py b/tests/test_demo_repo.py new file mode 100644 index 0000000..ad9aaeb --- /dev/null +++ b/tests/test_demo_repo.py @@ -0,0 +1,107 @@ +"""Regression tests against examples/cross-stack-demo. + +If a future refactor breaks the demo (the repo's own showcase), these tests +catch it before the demo's README starts lying. +""" +from __future__ import annotations + +from pathlib import Path + +import networkx as nx +import pytest + +from codegraph.graph.builder import GraphBuilder +from codegraph.graph.schema import EdgeKind +from codegraph.graph.store_networkx import to_digraph +from codegraph.graph.store_sqlite import SQLiteGraphStore + +DEMO_ROOT = Path(__file__).parent.parent / "examples" / "cross-stack-demo" + + +@pytest.fixture(scope="module") +def demo_graph(tmp_path_factory: pytest.TempPathFactory) -> nx.MultiDiGraph: + """Build the demo repo into a graph once per module.""" + db_path = tmp_path_factory.mktemp("demo") / "graph.db" + store = SQLiteGraphStore(db_path) + GraphBuilder(DEMO_ROOT, store).build(incremental=False) + g = to_digraph(store) + store.close() + return g + + +def _edges_of_kind(g: nx.MultiDiGraph, kind: str) -> list[tuple[str, str]]: + return [ + (str(s), str(d)) + for s, d, k in g.edges(keys=True) + if k == kind + ] + + +def _nodes_with_role(g: nx.MultiDiGraph, role: str) -> list[str]: + out: list[str] = [] + for nid, attrs in g.nodes(data=True): + meta = attrs.get("metadata") or {} + if isinstance(meta, dict) and meta.get("role") == role: + out.append(str(attrs.get("qualname") or nid)) + return out + + +def test_demo_repo_has_route_edges(demo_graph: nx.MultiDiGraph) -> None: + edges = _edges_of_kind(demo_graph, EdgeKind.ROUTE.value) + assert len(edges) >= 4, f"expected ≥4 ROUTE edges, got {len(edges)}" + + +def test_demo_repo_has_fetch_call_edges(demo_graph: nx.MultiDiGraph) -> None: + edges = _edges_of_kind(demo_graph, EdgeKind.FETCH_CALL.value) + assert len(edges) >= 2, f"expected ≥2 FETCH_CALL edges, got {len(edges)}" + + +def test_demo_repo_has_reads_from_edges(demo_graph: nx.MultiDiGraph) -> None: + edges = _edges_of_kind(demo_graph, EdgeKind.READS_FROM.value) + assert len(edges) >= 1, f"expected ≥1 READS_FROM edge, got {len(edges)}" + + +def test_demo_repo_has_writes_to_edges(demo_graph: nx.MultiDiGraph) -> None: + edges = _edges_of_kind(demo_graph, EdgeKind.WRITES_TO.value) + assert len(edges) >= 1, f"expected ≥1 WRITES_TO edge, got {len(edges)}" + + +def test_demo_repo_has_handler_role(demo_graph: nx.MultiDiGraph) -> None: + handlers = _nodes_with_role(demo_graph, "HANDLER") + assert len(handlers) >= 4, f"expected ≥4 HANDLER nodes, got {len(handlers)}" + + +def test_demo_repo_has_service_role(demo_graph: nx.MultiDiGraph) -> None: + services = _nodes_with_role(demo_graph, "SERVICE") + assert any("UserService" in s or "OrderService" in s for s in services), ( + f"expected at least one *Service in SERVICE role, got {services}" + ) + + +def test_demo_repo_has_repo_role(demo_graph: nx.MultiDiGraph) -> None: + repos = _nodes_with_role(demo_graph, "REPO") + assert any( + "UserRepository" in r or "OrderRepository" in r for r in repos + ), f"expected at least one *Repository in REPO role, got {repos}" + + +def test_demo_repo_has_component_role(demo_graph: nx.MultiDiGraph) -> None: + components = _nodes_with_role(demo_graph, "COMPONENT") + assert len(components) >= 1, ( + f"expected ≥1 COMPONENT node (UserCard / OrderList), got {components}" + ) + + +def test_demo_dataflow_trace_resolves(demo_graph: nx.MultiDiGraph) -> None: + """End-to-end: trace a known fetch URL through the demo and confirm we + cross from the frontend caller into a backend handler.""" + from codegraph.analysis.dataflow import trace + + flow = trace(demo_graph, "GET /api/users/{user_id}") + assert flow is not None + # Either we resolved into a handler chain, OR we got an explicit + # confidence=0 partial result. Both are valid; assert one of them. + if flow.hops: + layers = {h.layer for h in flow.hops} + # Should include backend at minimum (we entered via the route handler) + assert "backend" in layers