Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions codegraph/parsers/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<session-token>.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:
Expand Down
7 changes: 7 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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. |
74 changes: 74 additions & 0 deletions examples/cross-stack-demo/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Empty file.
8 changes: 8 additions & 0 deletions examples/cross-stack-demo/backend/api/main.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
24 changes: 24 additions & 0 deletions examples/cross-stack-demo/backend/api/routes/orders.py
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 31 additions & 0 deletions examples/cross-stack-demo/backend/api/routes/users.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions examples/cross-stack-demo/backend/models.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions examples/cross-stack-demo/backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
Empty file.
17 changes: 17 additions & 0 deletions examples/cross-stack-demo/backend/repositories/order_repository.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions examples/cross-stack-demo/backend/repositories/user_repository.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
18 changes: 18 additions & 0 deletions examples/cross-stack-demo/backend/services/order_service.py
Original file line number Diff line number Diff line change
@@ -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}
23 changes: 23 additions & 0 deletions examples/cross-stack-demo/backend/services/user_service.py
Original file line number Diff line number Diff line change
@@ -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}
8 changes: 8 additions & 0 deletions examples/cross-stack-demo/frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "cross-stack-demo-frontend",
"version": "0.0.1",
"private": true,
"dependencies": {
"react": "^18.2.0"
}
}
12 changes: 12 additions & 0 deletions examples/cross-stack-demo/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { OrderList } from "./components/OrderList";
import { UserCard } from "./components/UserCard";

export function App() {
const userId = 1;
return (
<div>
<UserCard userId={userId} />
<OrderList userId={userId} />
</div>
);
}
18 changes: 18 additions & 0 deletions examples/cross-stack-demo/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
return fetch(url, { method: "GET" }).then((r) => r.json());
},
post(url: string, body: object): Promise<unknown> {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => r.json());
},
};
43 changes: 43 additions & 0 deletions examples/cross-stack-demo/frontend/src/components/OrderList.tsx
Original file line number Diff line number Diff line change
@@ -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<Order[]>([]);

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 (
<div className="order-list">
<h3>Orders</h3>
<ul>
{orders.map((o) => (
<li key={o.id}>${(o.total_cents / 100).toFixed(2)}</li>
))}
</ul>
<button onClick={() => placeOrder(2500)}>Place $25 order</button>
</div>
);
}
Loading
Loading