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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,11 @@ AST is the default extractor. It is a **framework overlay**, not an import graph
| Surface | What Loadpath extracts |
| --- | --- |
| Models | Fields, FK / M2M / O2O, `on_delete`, string refs (`ForeignKey("accounts.User")`) as residuals |
| Serializers | `Meta.fields` / `exclude`, declared fields, `serializes` edges, queryset-in-serializer flag |
| Views | DRF ViewSets / APIViews, `serializer_class`, `get_serializer_class` (residual), `permission_classes`, `get_queryset`, `filterset_class`, `authentication_classes`, `pagination_class` |
| Serializers | `Meta.fields` / `exclude`, declared fields, nested serializers, `SerializerMethodField`, parsed `to_representation` keys, `serializes` edges, queryset-in-serializer flag |
| Views | DRF ViewSets / APIViews, `serializer_class`, `get_serializer_class` (resolved returns; residual only when unresolved), `permission_classes`, `get_queryset`, `filterset_class`, `authentication_classes`, `pagination_class` |
| Function views | `@api_view`, `@login_required`, `@csrf_exempt`, … |
| Django Ninja | `@router.get/post/…` routes and views |
| FastAPI (same repo) | `@app.get/post/…` and Pydantic `BaseModel` — only when the file imports FastAPI, so Ninja is not stolen |
| Django Ninja | `@router.get/post/…` routes and views, `Schema` / `ModelSchema` fields (including nested), response annotation → schema |
| FastAPI (same repo) | `@app.get/post/…` and Pydantic `BaseModel` (nested annotations) — only when the file imports FastAPI, so Ninja is not stolen |
| GraphQL | Strawberry `@strawberry.type` / `@strawberry.field` and Graphene `ObjectType` / `Mutation`; client `gql` documents stitch by operation/selection name |
| Channels | `WebsocketConsumer` subclasses and `path(..., Consumer.as_asgi())` websocket routes |
| Templates + HTMX | `.html` files, `{% url %}` / include / extends, `hx-get/post/…` stitched to Django routes |
Expand Down Expand Up @@ -301,9 +301,9 @@ AST is enough for review. If you need live `_meta` (db_table, resolved relations

## React + stitch

**React:** react-router tables, composition, TanStack Query `queryKey` + fetch/axios URL templates, Zod schemas, feature-folder imports, RTL `render(<Page/>)` as `tested_by`.
**React:** react-router tables, Next.js App Router (`app/**/page.tsx`) and Pages Router, Server Actions, composition, TanStack Query `queryKey` + fetch/axios URL templates, RTK Query `createApi` endpoints, openapi-fetch `client.GET/POST`, tRPC procedures, ts-rest `path:` contracts, Zod schemas, GraphQL codegen types, feature-folder imports, RTL `render(<Page/>)` and Playwright/Cypress `page.goto` / `cy.visit` as `tested_by`.

**Stitch (the moat):** OpenAPI from Spectacular/schema files first; generated clients (`generated/`, orval, openapi-typescript) as high-confidence `consumed_by_client`; FastAPI routes and GraphQL operations stitch the same way; HTMX URLs match Django routes; fallback URL-template matching and serializer/Zod field overlap marked **inferred**.
**Stitch (the moat):** OpenAPI from Spectacular/schema files first; generated clients (`generated/`, orval, openapi-typescript) and typed clients (RTK Query, openapi-fetch, ts-rest, tRPC) as high-confidence `consumed_by_client`; FastAPI routes, Ninja/Pydantic schemas, and GraphQL operations (including codegen types) stitch the same way; HTMX URLs and Playwright/Cypress visits match Django/React routes; fallback URL-template matching and serializer/Zod field overlap marked **inferred**.

Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/src`, `src-ui/src` — not a Python package `src/` and not `docs` / docs-site trees. `app/` is a Django root candidate.

Expand Down
16 changes: 13 additions & 3 deletions fixtures/demo_monorepo/backend/billing/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
from ninja import Router
from ninja import Router, Schema

from billing.models import Invoice

router = Router()


class LedgerLineSchema(Schema):
amount: str
kind: str


class InvoiceSchema(Schema):
total: str
lines: list[LedgerLineSchema]


@router.get("/invoices/{invoice_id}/ledger")
def invoice_ledger(request, invoice_id: int):
def invoice_ledger(request, invoice_id: int) -> InvoiceSchema:
invoice = Invoice.objects.get(pk=invoice_id)
return {"total": str(invoice.total)}
return InvoiceSchema(total=str(invoice.total), lines=[])
25 changes: 25 additions & 0 deletions fixtures/demo_monorepo/backend/billing/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,28 @@ class InvoiceSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = ["id", "customer_id", "total", "status"]


class LineSerializer(serializers.Serializer):
amount = serializers.DecimalField(max_digits=10, decimal_places=2)
kind = serializers.CharField()


class InvoiceDetailSerializer(serializers.ModelSerializer):
lines = LineSerializer(many=True)
display_total = serializers.SerializerMethodField()

class Meta:
model = Invoice
fields = ["id", "customer_id", "total", "status", "lines", "display_total"]

def get_display_total(self, obj):
return str(obj.total)

def to_representation(self, instance):
return {
"id": instance.id,
"total": str(instance.total),
"status": instance.status,
"display_total": str(instance.total),
}
12 changes: 11 additions & 1 deletion fixtures/demo_monorepo/backend/billing/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from billing.actors import rebuild_ledger
from billing.models import Invoice
from billing.serializers import InvoiceSerializer
from billing.serializers import InvoiceDetailSerializer, InvoiceSerializer
from billing.tasks import send_invoice_email


Expand Down Expand Up @@ -37,6 +37,16 @@ def perform_create(self, serializer):
return invoice


class InvoiceDetailViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated]
queryset = Invoice.objects.all()

def get_serializer_class(self):
if self.action == "list":
return InvoiceSerializer
return InvoiceDetailSerializer


class InvoiceBoardView(TemplateView):
template_name = "billing/invoice_board.html"

Expand Down
7 changes: 7 additions & 0 deletions fixtures/demo_monorepo/frontend/e2e/invoice.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { expect, test } from "@playwright/test";

test("invoice page load path", async ({ page }) => {
await page.goto("/invoices/1");
await page.request.get("/api/invoices/1");
await expect(page.getByRole("heading")).toBeVisible();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"use server";

export async function saveInvoice(formData: FormData) {
const id = String(formData.get("id") || "");
return id;
}
13 changes: 13 additions & 0 deletions fixtures/demo_monorepo/frontend/src/app/invoices/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { InvoiceForm } from "../../../features/billing/InvoiceForm";
import { saveInvoice } from "./actions";

export default function InvoicesPage({ params }: { params: { id: string } }) {
return (
<section>
<InvoiceForm invoice={{ id: params.id }} />
<form action={saveInvoice}>
<button type="submit">Save</button>
</form>
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
query Invoice($id: ID!) {
invoice {
total
status
}
}
14 changes: 14 additions & 0 deletions fixtures/demo_monorepo/frontend/src/features/billing/invoiceApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const invoiceApi = createApi({
reducerPath: "invoiceApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (builder) => ({
getInvoice: builder.query({
query: (id: string) => `/invoices/${id}`,
}),
saveInvoice: builder.mutation({
query: (body: unknown) => ({ url: "/invoices", method: "POST", body }),
}),
}),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare const client: {
GET: (path: string, init?: unknown) => Promise<unknown>;
};

export function getInvoiceTyped(id: string) {
return client.GET("/api/invoices/{id}", { params: { path: { id } } });
}
7 changes: 7 additions & 0 deletions fixtures/demo_monorepo/frontend/src/features/billing/trpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare const trpc: {
invoice: { get: { useQuery: (args: { id: string }) => unknown } };
};

export function useInvoiceRpc(id: string) {
return trpc.invoice.get.useQuery({ id });
}
12 changes: 12 additions & 0 deletions fixtures/demo_monorepo/frontend/src/generated/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** graphql-codegen types — stitch to InvoiceType by field overlap. */
export type InvoiceType = {
__typename?: "InvoiceType";
id: number;
total: number;
status: string;
};

export type InvoiceQuery = {
__typename?: "Query";
invoice?: InvoiceType | null;
};
2 changes: 1 addition & 1 deletion fixtures/demo_monorepo/loadpath.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
contexts:
billing:
django_apps: [billing]
react: [src/features/billing, frontend/src/features/billing]
react: [src/features/billing, frontend/src/features/billing, frontend/src/app/invoices]
public_api:
- "GET /api/invoices"
- "POST /api/invoices"
Expand Down
1 change: 1 addition & 0 deletions src/loadpath/architecture/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
NodeType.HOOK.value,
NodeType.API_CLIENT.value,
NodeType.FORM_SCHEMA.value,
NodeType.SERVER_ACTION.value,
NodeType.GRAPHQL_TYPE.value,
NodeType.GRAPHQL_OPERATION.value,
NodeType.FASTAPI_ROUTE.value,
Expand Down
1 change: 1 addition & 0 deletions src/loadpath/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class LoadpathConfig:
"**/generated/**/*.{ts,tsx,js}",
"**/*openapi*.{ts,js}",
"**/orval/**/*.{ts,js}",
"**/*graphql*.{ts,tsx,js}",
]
)
extra: dict[str, Any] = field(default_factory=dict)
Expand Down
1 change: 1 addition & 0 deletions src/loadpath/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ def _detect_django_root(repo_root: Path) -> str:
"client/src",
"ui/src",
"ui",
"src/app",
)

SKIP_REACT_PARTS = {
Expand Down
Loading
Loading