diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ab5288 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Python tests + run: | + python -m pip install -e ".[dev]" + python -m playwright install chromium + pytest + - name: UI tests + working-directory: ui + run: | + npm install + npm test + npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e179c39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +dist/ +build/ +node_modules/ +ui/dist/ +*.db +*.sqlite3 +.loadpath/ +.env +.DS_Store +.idea/ +.vscode/ +*.log +.playwright-mcp/ +ui/playwright-report/ +ui/test-results/ diff --git a/README.md b/README.md index deb3007..a2d5266 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ -# PR-Reviewer \ No newline at end of file +# Loadpath + +Review as **load-path inspection** on a Django + React architecture graph. Not another hunk-comment bot. + +A change is a force. Loadpath traces where that force travels until it hits a sink — HTTP response, UI, Celery/Dramatiq job, migration, permission — then scores whether you have enough evidence to merge. + +``` +Loadpath: MEDIUM — Invoice.total field change +Sinks: GET/POST /api/invoices/{id}; Celery send_invoice_email, apply_credit; Dramatiq rebuild_ledger; React InvoicePage + InvoiceForm +Tests: pytest hits serializer and view; no RTL test on InvoiceForm +Architecture: stays inside billing +Residual: total also formatted in a Signal update_ledger — no test +Suggested reviewers: billing-team +``` + +On the demo monorepo that path is: + +`Invoice.total → InvoiceSerializer → InvoiceViewSet → /api/invoices/{id} → OpenAPI → fetch → useInvoice → InvoicePage → InvoiceForm / Zod` + +plus the jobs the view enqueues (`send_invoice_email.delay`, `rebuild_ledger.send`). + +## App + +`loadpath serve --port 7345` opens a local desktop-style UI. Tokens stay on the machine in `~/.loadpath/settings.json`. AI is used **only** for residual uncertainty the graph cannot close. + +### Review + +Confidence brief, read-order, clusters, architecture findings on the impact path, residual list, and the subgraph for the git range. Review walks the **indexed** graph (incremental refresh by default). + +![Review](docs/screenshots/review.png) + +### Architecture + +Index a repo first. The architecture tab is the full typed graph plus `loadpath.yml` contexts and rules — not a PR diff. Findings here are repo-wide; review then scopes them to the change. + +![Architecture](docs/screenshots/architecture.png) + +### Impact graph + +Toggle **This review** (impact subgraph) vs **Indexed architecture** (the repo map). Dashed edges are inferred (URL/Zod overlap); solid edges are extracted or generated-client stitches. + +![Impact graph](docs/screenshots/graph.png) + +### Pull requests + +GitHub and Bitbucket via API tokens from Settings. Pick a PR and jump to a branch-range review. + +![Pull requests](docs/screenshots/pull-requests.png) + +### Settings + +GitHub / Bitbucket tokens; AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama). Residual analysis only — Loadpath does not comment every hunk. + +![Settings](docs/screenshots/settings.png) + +## Install + +Python 3.12+ and Node 22+ (UI). + +```bash +pip install -e ".[dev]" +python -m playwright install chromium # optional, for UI screenshot tests +cd ui && npm install && npm run build && cd .. +loadpath --help +``` + +## CLI + +```bash +# Index a monorepo (SQLite graph at .loadpath/graph.sqlite3, incremental on file hashes) +loadpath index /path/to/repo + +# Inspect bounded contexts, rules, and type counts from that index +loadpath architecture /path/to/repo + +# Review a git range against the index (incremental refresh; --no-reindex to reuse as-is) +loadpath review /path/to/repo --base origin/main --head HEAD +loadpath review /path/to/repo --base origin/main --no-reindex + +# Cross-platform app (API + visual graph + PR list) +loadpath serve --port 7345 +``` + +**Flow:** `index` builds the architecture graph → `architecture` shows contexts and rule hits on the whole repo → `review` walks that same graph for a git range. The app mirrors this: Index registers a workspace, Architecture inspects it, Review traces a change through it. + +Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example) and [`fixtures/demo_monorepo/loadpath.yml`](fixtures/demo_monorepo/loadpath.yml)). The tool is opinionated about *your* architecture, not a generic module graph. + +## Django support + +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` | +| Function views | `@api_view`, `@login_required`, `@csrf_exempt`, … | +| Django Ninja | `@router.get/post/…` routes and views | +| URLs | `path` / `re_path`, DRF `router.register`, `include()` mount composition (`/api` + `invoices//` → `/api/invoices/{id}`) | +| Signals | `@receiver`, `signal.connect()` residual, `AppConfig.ready()` residual | +| Management commands | `BaseCommand` + `handle()`, including `.delay(` / `.send(` enqueue edges | +| Migrations | `CreateModel` / `AddField` / `RemoveField` / `DeleteModel` / `RunPython` as `destructive_migration` | +| Tests | `test_*` in `tests.py` / `tests/` as `tested_by` | + +### Celery + +- `@shared_task`, `@app.task`, `@periodic_task` +- `celery.Task` subclasses (`run(self, invoice_id)`) +- Enqueue: `.delay(`, `.apply_async(` +- Signatures / canvas: `.s(`, `.si(`, `chain` / `group` / `chord` (canvas is a residual; inner signatures are `enqueues` edges) +- `current_app.send_task("billing.tasks.send_invoice_email")` — residual + inferred enqueue +- `transaction.on_commit(lambda: task.apply_async(...))` — residual, nested enqueue walked +- `CELERY_BEAT_SCHEDULE` / `beat_schedule` in settings + +### Dramatiq + +- `@dramatiq.actor` +- `dramatiq.GenericActor` subclasses (`perform(self, invoice_id)`) +- Enqueue: `.send(`, `.send_with_options(` (heuristic: dramatiq import or `actors` / `tasks` module) + +Call-site placeholders (`rebuild_ledger.send` in a view) do **not** overwrite the actor definition’s file. The graph keeps `actors.py` / `tasks.py` as the node home. + +### Idempotency rule + +`celery_tasks_must_be_idempotent_on_model_pk` (alias `async_tasks_must_be_idempotent_on_model_pk`) warns when a Celery **or** Dramatiq task takes a full object payload instead of `pk` / `*_id`. Message names the broker. + +### Optional `django.setup()` overlay + +AST is enough for review. If you need live `_meta` (db_table, resolved relations), set `boot_django: true` in `loadpath.yml`. Loadpath then imports Django, calls `django.setup()`, and merges model/field nodes. Failures become residuals; the AST graph still stands. Leave it `false` in CI unless the fixture is a bootable project. + +## React + stitch + +**React:** react-router tables, composition, TanStack Query `queryKey` + fetch/axios URL templates, Zod schemas, feature-folder imports, RTL `render()` as `tested_by`. + +**Stitch (the moat):** OpenAPI from Spectacular/schema files first; generated clients (`generated/`, orval, openapi-typescript) as high-confidence `consumed_by_client`; fallback URL-template matching and serializer/Zod field overlap marked **inferred**. + +## Architecture rules (`loadpath.yml`) + +| Rule | Meaning | +| --- | --- | +| `views_cannot_import_other_context_models` | A billing view must not import `accounts.UserProfile` | +| `react_feature_may_only_call_own_or_shared_api` | Billing UI may not `fetch('/api/me')` | +| `serializers_are_the_only_published_contract` | Zod/form fields must not drift from the serializer | +| `no_queryset_in_serializer` | Serializers must not run querysets | +| `celery_tasks_must_be_idempotent_on_model_pk` | Celery and Dramatiq tasks take a model pk | + +Waivers live under `waivers:` in the same file. Reviewers are the `owners` of the bounded contexts in the impact subgraph. + +Impact walk skips permission/app/context hubs, does not climb `renders` into the App shell, and does not follow cross-context `relates_to` (an Invoice FK to UserProfile does not pull identity into a billing review). + +## How confidence is scored + +Line coverage on changed files is the wrong metric. Loadpath scores the **impact subgraph**: + +| Signal | High | Low | +| --- | --- | --- | +| Tests | Sinks in the radius are hit by tests that still reach the changed symbol | Serializer changed, tests only on the view happy path | +| Contract | OpenAPI/client types track the serializer | React path/Zod field still old | +| Architecture | No new cross-context edges | `crosses_context` with no waiver | +| Graph | Resolved edges | Many inferred/dynamic edges | + +`high` / `medium` / `low` plus three reasons. Isolated leaf UI with green tests and no rule hits is labeled `loadpath:low-risk`. + +## Tests + +```bash +pytest +cd ui && npm test +``` + +| Suite | What it covers | +| --- | --- | +| `tests/unit/` | Django/React extractors, architecture rules, stitch, SCM/AI providers | +| `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage; reviewers `billing-team` | +| `tests/e2e/test_cli_review.py` | `loadpath index` / `architecture` / `review` markdown, JSON, HTML | +| `tests/e2e/test_api_flow.py` | health, index, architecture, review-from-index, graph, settings, GitHub + Bitbucket PR list | +| `tests/e2e/test_index_architecture_flow.py` | index snapshot, review without index, review walking an existing graph | +| `tests/e2e/test_brokers_and_django.py` | Celery + Dramatiq sinks, actor-only PR, non-idempotent Dramatiq warning, destructive migration, cross-context blocker, boot overlay, management commands, beat/canvas | +| `tests/e2e/test_ui_screenshots.py` | Playwright: Architecture, Review, Impact graph, Pull requests, Settings → `docs/screenshots/` | + +CI installs Chromium and runs the full suite. + +## Demo fixture + +[`fixtures/demo_monorepo`](fixtures/demo_monorepo) is a billing/identity split: DRF ViewSet, FBV, Ninja ledger route, Celery tasks + beat + canvas, Dramatiq actor + GenericActor, management command, signal, FK string ref, React InvoicePage/Zod. + +## What this is not + +Not CodeRabbit (comments without a closed impact set). Not CodeScene (historical coupling). Not django-orm-lens (models only). Not a generic SCIP call graph. The product is review as load-path inspection. diff --git a/docs/screenshots/architecture.png b/docs/screenshots/architecture.png new file mode 100644 index 0000000..a965ff4 Binary files /dev/null and b/docs/screenshots/architecture.png differ diff --git a/docs/screenshots/graph.png b/docs/screenshots/graph.png new file mode 100644 index 0000000..71419ed Binary files /dev/null and b/docs/screenshots/graph.png differ diff --git a/docs/screenshots/pull-requests.png b/docs/screenshots/pull-requests.png new file mode 100644 index 0000000..d42ed19 Binary files /dev/null and b/docs/screenshots/pull-requests.png differ diff --git a/docs/screenshots/review.png b/docs/screenshots/review.png new file mode 100644 index 0000000..c00fd7d Binary files /dev/null and b/docs/screenshots/review.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..80dd3d8 Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/fixtures/demo_monorepo/backend/accounts/__init__.py b/fixtures/demo_monorepo/backend/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/demo_monorepo/backend/accounts/apps.py b/fixtures/demo_monorepo/backend/accounts/apps.py new file mode 100644 index 0000000..fb0257e --- /dev/null +++ b/fixtures/demo_monorepo/backend/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = "accounts" diff --git a/fixtures/demo_monorepo/backend/accounts/models.py b/fixtures/demo_monorepo/backend/accounts/models.py new file mode 100644 index 0000000..68cb07e --- /dev/null +++ b/fixtures/demo_monorepo/backend/accounts/models.py @@ -0,0 +1,9 @@ +from django.db import models + + +class UserProfile(models.Model): + email = models.EmailField() + display_name = models.CharField(max_length=120) + + class Meta: + app_label = "accounts" diff --git a/fixtures/demo_monorepo/backend/accounts/serializers.py b/fixtures/demo_monorepo/backend/accounts/serializers.py new file mode 100644 index 0000000..5ad60d7 --- /dev/null +++ b/fixtures/demo_monorepo/backend/accounts/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from accounts.models import UserProfile + + +class MeSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + fields = ["id", "email", "display_name"] diff --git a/fixtures/demo_monorepo/backend/accounts/urls.py b/fixtures/demo_monorepo/backend/accounts/urls.py new file mode 100644 index 0000000..dd9bba6 --- /dev/null +++ b/fixtures/demo_monorepo/backend/accounts/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from accounts.views import MeView + +urlpatterns = [ + path("me/", MeView.as_view(), name="me"), + path("login/", MeView.as_view(), name="login"), +] diff --git a/fixtures/demo_monorepo/backend/accounts/views.py b/fixtures/demo_monorepo/backend/accounts/views.py new file mode 100644 index 0000000..6fe9276 --- /dev/null +++ b/fixtures/demo_monorepo/backend/accounts/views.py @@ -0,0 +1,13 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from accounts.serializers import MeSerializer + + +class MeView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = MeSerializer + + def get(self, request): + return Response({"email": request.user.email}) diff --git a/fixtures/demo_monorepo/backend/billing/__init__.py b/fixtures/demo_monorepo/backend/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/demo_monorepo/backend/billing/actors.py b/fixtures/demo_monorepo/backend/billing/actors.py new file mode 100644 index 0000000..5f224a3 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/actors.py @@ -0,0 +1,15 @@ +import dramatiq + +from billing.models import Invoice + + +@dramatiq.actor +def rebuild_ledger(invoice_id): + invoice = Invoice.objects.get(pk=invoice_id) + return str(invoice.total) + + +class NotifyCustomer(dramatiq.GenericActor): + def perform(self, invoice_id): + invoice = Invoice.objects.get(pk=invoice_id) + return invoice.status diff --git a/fixtures/demo_monorepo/backend/billing/admin.py b/fixtures/demo_monorepo/backend/billing/admin.py new file mode 100644 index 0000000..c72ca2c --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from billing.models import Invoice + + +@admin.register(Invoice) +class InvoiceAdmin(admin.ModelAdmin): + list_display = ["id", "total", "status"] diff --git a/fixtures/demo_monorepo/backend/billing/api.py b/fixtures/demo_monorepo/backend/billing/api.py new file mode 100644 index 0000000..4f0afa6 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/api.py @@ -0,0 +1,11 @@ +from ninja import Router + +from billing.models import Invoice + +router = Router() + + +@router.get("/invoices/{invoice_id}/ledger") +def invoice_ledger(request, invoice_id: int): + invoice = Invoice.objects.get(pk=invoice_id) + return {"total": str(invoice.total)} diff --git a/fixtures/demo_monorepo/backend/billing/apps.py b/fixtures/demo_monorepo/backend/billing/apps.py new file mode 100644 index 0000000..e057ee3 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + name = "billing" + + def ready(self): + from billing import signals # noqa: F401 diff --git a/fixtures/demo_monorepo/backend/billing/management/__init__.py b/fixtures/demo_monorepo/backend/billing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/demo_monorepo/backend/billing/management/commands/__init__.py b/fixtures/demo_monorepo/backend/billing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/demo_monorepo/backend/billing/management/commands/resend_invoices.py b/fixtures/demo_monorepo/backend/billing/management/commands/resend_invoices.py new file mode 100644 index 0000000..739964e --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/management/commands/resend_invoices.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from billing.models import Invoice +from billing.tasks import send_invoice_email + + +class Command(BaseCommand): + help = "Re-send invoice emails" + + def handle(self, *args, **options): + for invoice in Invoice.objects.all(): + send_invoice_email.delay(invoice.id) diff --git a/fixtures/demo_monorepo/backend/billing/migrations/0001_initial.py b/fixtures/demo_monorepo/backend/billing/migrations/0001_initial.py new file mode 100644 index 0000000..3b4489f --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/migrations/0001_initial.py @@ -0,0 +1,14 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + operations = [ + migrations.CreateModel( + name="Invoice", + fields=[ + ("id", models.AutoField(primary_key=True)), + ("total", models.DecimalField(max_digits=10, decimal_places=2)), + ], + ), + ] diff --git a/fixtures/demo_monorepo/backend/billing/migrations/__init__.py b/fixtures/demo_monorepo/backend/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/demo_monorepo/backend/billing/models.py b/fixtures/demo_monorepo/backend/billing/models.py new file mode 100644 index 0000000..a77e229 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class Invoice(models.Model): + customer_id = models.IntegerField() + total = models.DecimalField(max_digits=10, decimal_places=2) + status = models.CharField(max_length=32, default="draft") + created_at = models.DateTimeField(auto_now_add=True) + account = models.ForeignKey("accounts.UserProfile", null=True, on_delete=models.SET_NULL) + + class Meta: + app_label = "billing" diff --git a/fixtures/demo_monorepo/backend/billing/serializers.py b/fixtures/demo_monorepo/backend/billing/serializers.py new file mode 100644 index 0000000..485530b --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from billing.models import Invoice + + +class InvoiceSerializer(serializers.ModelSerializer): + class Meta: + model = Invoice + fields = ["id", "customer_id", "total", "status"] diff --git a/fixtures/demo_monorepo/backend/billing/services.py b/fixtures/demo_monorepo/backend/billing/services.py new file mode 100644 index 0000000..6cdffeb --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/services.py @@ -0,0 +1,7 @@ +from billing.models import Invoice + + +def recalculate_total(invoice: Invoice) -> Invoice: + invoice.total = invoice.total + invoice.save(update_fields=["total"]) + return invoice diff --git a/fixtures/demo_monorepo/backend/billing/signals.py b/fixtures/demo_monorepo/backend/billing/signals.py new file mode 100644 index 0000000..371aa81 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/signals.py @@ -0,0 +1,10 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from billing.models import Invoice + + +@receiver(post_save, sender=Invoice) +def update_ledger(sender, instance, **kwargs): + # Formats Invoice.total into a ledger row — no test covers this. + _ = f"{instance.total:.2f}" diff --git a/fixtures/demo_monorepo/backend/billing/tasks.py b/fixtures/demo_monorepo/backend/billing/tasks.py new file mode 100644 index 0000000..9452170 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/tasks.py @@ -0,0 +1,32 @@ +from celery import Task, chain, current_app, shared_task +from django.db import transaction + +from billing.models import Invoice + + +@shared_task(bind=False) +def send_invoice_email(invoice_id): + invoice = Invoice.objects.get(pk=invoice_id) + return invoice.total + + +@shared_task +def apply_credit(invoice_id): + Invoice.objects.filter(pk=invoice_id).update(status="credited") + + +class RecalcTotals(Task): + name = "billing.recalc_totals" + + def run(self, invoice_id): + invoice = Invoice.objects.get(pk=invoice_id) + return invoice.total + + +def enqueue_email(invoice_id): + transaction.on_commit(lambda: send_invoice_email.apply_async(args=[invoice_id])) + + +def settle_invoice(invoice_id): + chain(apply_credit.s(invoice_id), send_invoice_email.si(invoice_id)).delay() + current_app.send_task("billing.tasks.send_invoice_email", args=[invoice_id]) diff --git a/fixtures/demo_monorepo/backend/billing/tests.py b/fixtures/demo_monorepo/backend/billing/tests.py new file mode 100644 index 0000000..f72ec71 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/tests.py @@ -0,0 +1,16 @@ +from rest_framework.test import APITestCase + +from billing.models import Invoice +from billing.serializers import InvoiceSerializer +from billing.views import InvoiceViewSet + + +class InvoiceSerializerTests(APITestCase): + def test_serializer_includes_total(self): + data = InvoiceSerializer(Invoice(customer_id=1, total="10.00", status="draft")).data + assert "total" in data + + +class InvoiceViewTests(APITestCase): + def test_viewset_uses_invoice_serializer(self): + assert InvoiceViewSet.serializer_class is InvoiceSerializer diff --git a/fixtures/demo_monorepo/backend/billing/urls.py b/fixtures/demo_monorepo/backend/billing/urls.py new file mode 100644 index 0000000..d25e864 --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from billing.views import InvoiceViewSet, invoice_totals + +router = DefaultRouter() +router.register("invoices", InvoiceViewSet, basename="invoice") + +urlpatterns = [ + path("", include(router.urls)), + path("invoices/totals/", invoice_totals, name="invoice-totals"), + path( + "invoices//", + InvoiceViewSet.as_view({"get": "retrieve", "put": "update"}), + name="invoice-detail", + ), +] diff --git a/fixtures/demo_monorepo/backend/billing/views.py b/fixtures/demo_monorepo/backend/billing/views.py new file mode 100644 index 0000000..d5f8a2c --- /dev/null +++ b/fixtures/demo_monorepo/backend/billing/views.py @@ -0,0 +1,36 @@ +from rest_framework import viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from billing.actors import rebuild_ledger +from billing.models import Invoice +from billing.serializers import InvoiceSerializer +from billing.tasks import send_invoice_email + + +class InvoiceFilter: + """Stand-in for django-filter FilterSet (parsed as filterset_class).""" + + +class InvoiceViewSet(viewsets.ModelViewSet): + serializer_class = InvoiceSerializer + permission_classes = [IsAuthenticated] + filterset_class = InvoiceFilter + queryset = Invoice.objects.all() + + def get_queryset(self): + return Invoice.objects.select_related() + + def perform_create(self, serializer): + invoice = serializer.save() + send_invoice_email.delay(invoice.id) + rebuild_ledger.send(invoice.id) + rebuild_ledger.send_with_options(args=(invoice.id,)) + return invoice + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def invoice_totals(request): + return Response({"count": Invoice.objects.count()}) diff --git a/fixtures/demo_monorepo/backend/config/__init__.py b/fixtures/demo_monorepo/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/demo_monorepo/backend/config/settings.py b/fixtures/demo_monorepo/backend/config/settings.py new file mode 100644 index 0000000..0c0b095 --- /dev/null +++ b/fixtures/demo_monorepo/backend/config/settings.py @@ -0,0 +1,21 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parents[1] + +SECRET_KEY = "loadpath-demo-not-for-production" +DEBUG = True +INSTALLED_APPS = [ + "django.contrib.contenttypes", + "django.contrib.auth", + "billing", + "accounts", +] +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} +USE_TZ = True +ROOT_URLCONF = "config.urls" +CELERY_BEAT_SCHEDULE = { + "credit-stale-invoices": { + "task": "billing.tasks.apply_credit", + "schedule": 3600, + } +} diff --git a/fixtures/demo_monorepo/backend/config/urls.py b/fixtures/demo_monorepo/backend/config/urls.py new file mode 100644 index 0000000..8b56684 --- /dev/null +++ b/fixtures/demo_monorepo/backend/config/urls.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("billing.urls")), + path("api/", include("accounts.urls")), +] diff --git a/fixtures/demo_monorepo/frontend/src/App.tsx b/fixtures/demo_monorepo/frontend/src/App.tsx new file mode 100644 index 0000000..0771b52 --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/App.tsx @@ -0,0 +1,11 @@ +import { InvoicePage } from "./features/billing/InvoicePage"; +import { MePage } from "./features/auth/MePage"; + +export function App() { + return ( + + } /> + } /> + + ); +} diff --git a/fixtures/demo_monorepo/frontend/src/features/auth/MePage.tsx b/fixtures/demo_monorepo/frontend/src/features/auth/MePage.tsx new file mode 100644 index 0000000..c73e9a2 --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/features/auth/MePage.tsx @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; + +export function useMe() { + return useQuery({ + queryKey: ["me"], + queryFn: () => fetch("/api/me").then((r) => r.json()), + }); +} + +export function MePage() { + const { data } = useMe(); + return
{data?.email}
; +} diff --git a/fixtures/demo_monorepo/frontend/src/features/billing/InvoiceForm.tsx b/fixtures/demo_monorepo/frontend/src/features/billing/InvoiceForm.tsx new file mode 100644 index 0000000..78dae27 --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/features/billing/InvoiceForm.tsx @@ -0,0 +1,14 @@ +import { invoiceSchema } from "./invoiceSchema"; + +export function InvoiceForm({ invoice }) { + const parsed = invoiceSchema.parse({ + customer_id: invoice?.customer_id, + total: invoice?.total, + status: invoice?.status, + }); + return ( +
+ +
+ ); +} diff --git a/fixtures/demo_monorepo/frontend/src/features/billing/InvoicePage.tsx b/fixtures/demo_monorepo/frontend/src/features/billing/InvoicePage.tsx new file mode 100644 index 0000000..b73f66f --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/features/billing/InvoicePage.tsx @@ -0,0 +1,12 @@ +import { InvoiceForm } from "./InvoiceForm"; +import { useInvoice } from "./useInvoice"; + +export function InvoicePage() { + const { data } = useInvoice("1"); + return ( +
+

Invoice {data?.id}

+ +
+ ); +} diff --git a/fixtures/demo_monorepo/frontend/src/features/billing/api.ts b/fixtures/demo_monorepo/frontend/src/features/billing/api.ts new file mode 100644 index 0000000..63be4b2 --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/features/billing/api.ts @@ -0,0 +1,12 @@ +export async function fetchInvoice(id: string) { + const res = await fetch(`/api/invoices/${id}`); + return res.json(); +} + +export async function updateInvoice(id: string, body: unknown) { + const res = await fetch(`/api/invoices/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + return res.json(); +} diff --git a/fixtures/demo_monorepo/frontend/src/features/billing/invoiceSchema.ts b/fixtures/demo_monorepo/frontend/src/features/billing/invoiceSchema.ts new file mode 100644 index 0000000..c3ca8e4 --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/features/billing/invoiceSchema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const invoiceSchema = z.object({ + customer_id: z.number(), + total: z.string(), + status: z.string(), +}); diff --git a/fixtures/demo_monorepo/frontend/src/features/billing/useInvoice.ts b/fixtures/demo_monorepo/frontend/src/features/billing/useInvoice.ts new file mode 100644 index 0000000..bbb496e --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/features/billing/useInvoice.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchInvoice } from "./api"; + +export function useInvoice(id: string) { + return useQuery({ + queryKey: ["invoice", id], + queryFn: () => fetchInvoice(id), + }); +} diff --git a/fixtures/demo_monorepo/frontend/src/generated/invoices.ts b/fixtures/demo_monorepo/frontend/src/generated/invoices.ts new file mode 100644 index 0000000..7bebdd3 --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/generated/invoices.ts @@ -0,0 +1,2 @@ +/** Generated from OpenAPI — high-confidence stitch when used. */ +export const getInvoice = (id: string) => fetch(`/api/invoices/${id}`); diff --git a/fixtures/demo_monorepo/frontend/src/shared/api.ts b/fixtures/demo_monorepo/frontend/src/shared/api.ts new file mode 100644 index 0000000..74aeb3c --- /dev/null +++ b/fixtures/demo_monorepo/frontend/src/shared/api.ts @@ -0,0 +1,4 @@ +export async function apiGet(path: string) { + const res = await fetch(path); + return res.json(); +} diff --git a/fixtures/demo_monorepo/loadpath.yml b/fixtures/demo_monorepo/loadpath.yml new file mode 100644 index 0000000..30f8bec --- /dev/null +++ b/fixtures/demo_monorepo/loadpath.yml @@ -0,0 +1,32 @@ +# Loadpath architecture manifest — demo billing/identity split. +contexts: + billing: + django_apps: [billing] + react: [src/features/billing, frontend/src/features/billing] + public_api: + - "GET /api/invoices" + - "POST /api/invoices" + - "GET /api/invoices/{id}" + - "PUT /api/invoices/{id}" + owners: [billing-team] + identity: + django_apps: [accounts] + react: [src/features/auth, frontend/src/features/auth] + public_api: + - "POST /api/login" + - "GET /api/me" + owners: [identity-team] +layers: + django: [route, view, service, model] + react: [route, page, feature, shared] +rules: + - views_cannot_import_other_context_models + - react_feature_may_only_call_own_or_shared_api + - serializers_are_the_only_published_contract + - no_queryset_in_serializer + - celery_tasks_must_be_idempotent_on_model_pk +django_root: backend +react_root: frontend/src +openapi_paths: + - openapi.yaml +boot_django: false diff --git a/fixtures/demo_monorepo/openapi.yaml b/fixtures/demo_monorepo/openapi.yaml new file mode 100644 index 0000000..4749932 --- /dev/null +++ b/fixtures/demo_monorepo/openapi.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Demo API + version: "1.0.0" +paths: + /api/invoices: + get: + operationId: listInvoices + responses: + "200": + description: ok + post: + operationId: createInvoice + responses: + "201": + description: created + /api/invoices/{id}: + get: + operationId: getInvoice + responses: + "200": + description: ok + put: + operationId: updateInvoice + responses: + "200": + description: ok + /api/me: + get: + operationId: getMe + responses: + "200": + description: ok + /api/login: + post: + operationId: login + responses: + "200": + description: ok diff --git a/loadpath.yml.example b/loadpath.yml.example new file mode 100644 index 0000000..1a2368f --- /dev/null +++ b/loadpath.yml.example @@ -0,0 +1,30 @@ +# Loadpath architecture manifest +contexts: + billing: + django_apps: [billing] + react: [frontend/src/features/billing] + public_api: + - "GET /api/invoices" + - "POST /api/invoices" + - "GET /api/invoices/{id}" + owners: [billing-team] + identity: + django_apps: [accounts] + react: [frontend/src/features/auth] + public_api: + - "POST /api/login" + - "GET /api/me" + owners: [identity-team] +layers: + django: [route, view, service, model] + react: [route, page, feature, shared] +rules: + - views_cannot_import_other_context_models + - react_feature_may_only_call_own_or_shared_api + - serializers_are_the_only_published_contract + - no_queryset_in_serializer + - celery_tasks_must_be_idempotent_on_model_pk +django_root: backend +react_root: frontend/src +openapi_paths: [] +boot_django: false diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe8fb9b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "loadpath" +version = "0.1.0" +description = "Architecture-typed impact graphs for Django + React pull requests." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "Loadpath" }] +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "pydantic-settings>=2.6.0", + "pyyaml>=6.0.2", + "httpx>=0.27.0", + "jinja2>=3.1.4", + "typer>=0.15.0", + "rich>=13.9.0", + "python-multipart>=0.0.12", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "respx>=0.21.0", + "playwright>=1.49.0", +] + +[project.scripts] +loadpath = "loadpath.cli:app" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +loadpath = ["report/*.html", "static/**/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-q --tb=short" +markers = [ + "playwright: UI tests that need Chromium", +] +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" diff --git a/src/loadpath/__init__.py b/src/loadpath/__init__.py new file mode 100644 index 0000000..8238ae1 --- /dev/null +++ b/src/loadpath/__init__.py @@ -0,0 +1,3 @@ +"""Loadpath: architecture-typed impact graphs for Django + React PRs.""" + +__version__ = "0.1.0" diff --git a/src/loadpath/ai/__init__.py b/src/loadpath/ai/__init__.py new file mode 100644 index 0000000..1809198 --- /dev/null +++ b/src/loadpath/ai/__init__.py @@ -0,0 +1,3 @@ +from loadpath.ai.providers import client_for, residual_prompt + +__all__ = ["client_for", "residual_prompt"] diff --git a/src/loadpath/ai/providers.py b/src/loadpath/ai/providers.py new file mode 100644 index 0000000..f4a1103 --- /dev/null +++ b/src/loadpath/ai/providers.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Protocol + +import httpx + +RESIDUAL_SYSTEM = """You are Loadpath's residual-uncertainty analyst. +You do NOT comment on every hunk. You only inspect dynamic/inferred coupling +the deterministic graph could not close: getattr, raw SQL, get_serializer_class, +AppConfig.ready() signal registration, string model refs, inferred URL stitches. +Return a short markdown note: what might still be coupled, how sure you are, and +what a reviewer should verify. No style nits. No generic praise. +""" + + +class CompletionClient(Protocol): + name: str + + def complete(self, prompt: str, system: str = RESIDUAL_SYSTEM) -> str: ... + + +class OpenAICompatible: + name = "openai" + + def __init__( + self, + api_key: str, + model: str, + base_url: str = "https://api.openai.com/v1", + client: httpx.Client | None = None, + ) -> None: + self.api_key = api_key + self.model = model + self.base_url = base_url.rstrip("/") + self.client = client or httpx.Client(timeout=60.0) + + def complete(self, prompt: str, system: str = RESIDUAL_SYSTEM) -> str: + r = self.client.post( + f"{self.base_url}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}"}, + json={ + "model": self.model, + "temperature": 0.1, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + }, + ) + r.raise_for_status() + data = r.json() + return data["choices"][0]["message"]["content"] + + +class AnthropicClient: + name = "anthropic" + + def __init__( + self, + api_key: str, + model: str = "claude-sonnet-4-20250514", + client: httpx.Client | None = None, + ) -> None: + self.api_key = api_key + self.model = model + self.client = client or httpx.Client(timeout=60.0) + + def complete(self, prompt: str, system: str = RESIDUAL_SYSTEM) -> str: + r = self.client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + json={ + "model": self.model, + "max_tokens": 800, + "system": system, + "messages": [{"role": "user", "content": prompt}], + }, + ) + r.raise_for_status() + data = r.json() + parts = data.get("content") or [] + return "".join(p.get("text", "") for p in parts if p.get("type") == "text") + + +PROVIDERS = { + "openai": ("https://api.openai.com/v1", "gpt-4.1-mini"), + "grok": ("https://api.x.ai/v1", "grok-3"), + "xai": ("https://api.x.ai/v1", "grok-3"), + "deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"), + "cursor": ("https://api.openai.com/v1", "gpt-4.1-mini"), + "ollama": ("http://127.0.0.1:11434/v1", "llama3.1"), +} + + +def client_for(provider: str, api_key: str, model: str = "", base_url: str = "") -> CompletionClient: + name = provider.lower().strip() + if name in {"none", "", "off"}: + raise ValueError("No AI provider configured") + if name == "anthropic": + return AnthropicClient(api_key, model=model or "claude-sonnet-4-20250514") + url, default_model = PROVIDERS.get(name, (base_url or "https://api.openai.com/v1", "gpt-4.1-mini")) + return OpenAICompatible(api_key, model=model or default_model, base_url=base_url or url) + + +def residual_prompt(review: dict) -> str: + residuals = review.get("residuals") or [] + findings = [f for f in review.get("findings") or [] if not f.get("waived")] + path = [] + for n in review.get("nodes") or []: + path.append(f"- {n.get('type')} {n.get('qualified_name')} ({n.get('file_path')})") + return ( + f"Change: {review.get('title')}\n" + f"Confidence: {review.get('confidence', {}).get('level')}\n" + f"Kinds: {review.get('change_kinds')}\n\n" + f"Residuals:\n" + "\n".join(f"- {r}" for r in residuals) + "\n\n" + f"Architecture findings:\n" + + "\n".join(f"- {f.get('rule')}: {f.get('message')}" for f in findings) + + "\n\nImpact nodes:\n" + + "\n".join(path[:80]) + ) diff --git a/src/loadpath/architecture/__init__.py b/src/loadpath/architecture/__init__.py new file mode 100644 index 0000000..e549953 --- /dev/null +++ b/src/loadpath/architecture/__init__.py @@ -0,0 +1,4 @@ +from loadpath.architecture.rules import Finding, evaluate +from loadpath.architecture.snapshot import architecture_report, summarize_index + +__all__ = ["Finding", "evaluate", "architecture_report", "summarize_index"] diff --git a/src/loadpath/architecture/rules.py b/src/loadpath/architecture/rules.py new file mode 100644 index 0000000..5fef789 --- /dev/null +++ b/src/loadpath/architecture/rules.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from loadpath.config import LoadpathConfig +from loadpath.extractors.react import normalize_url_template +from loadpath.graph.store import GraphStore +from loadpath.stitch.openapi import django_route_to_template, parse_public_api +from loadpath.types import Edge, EdgeType, Node, NodeType, RuleSeverity, node_id + +RULE_DOCS = { + "views_cannot_import_other_context_models": "Views must not import models from another bounded context.", + "react_feature_may_only_call_own_or_shared_api": "A React feature may only call its own public API or shared clients.", + "serializers_are_the_only_published_contract": "Serializers (and OpenAPI) are the only published contract; React must not drift.", + "no_queryset_in_serializer": "Serializers must not run querysets.", + "celery_tasks_must_be_idempotent_on_model_pk": "Celery and Dramatiq tasks must take a model pk/id, not a full object payload.", + "async_tasks_must_be_idempotent_on_model_pk": "Celery and Dramatiq tasks must take a model pk/id, not a full object payload.", +} + + +@dataclass +class Finding: + rule: str + severity: RuleSeverity + message: str + node_id: str | None = None + file_path: str | None = None + waived: bool = False + extra: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "rule": self.rule, + "severity": self.severity.value, + "message": self.message, + "node_id": self.node_id, + "file_path": self.file_path, + "waived": self.waived, + "extra": self.extra, + } + + +def _waived(config: LoadpathConfig, rule: str, node: str | None) -> bool: + for w in config.waivers: + if w.rule != rule: + continue + if w.node is None or w.node == node: + return True + return False + + +def evaluate(store: GraphStore, config: LoadpathConfig, changed_ids: set[str] | None = None) -> list[Finding]: + findings: list[Finding] = [] + enabled = set(config.rules) + + if "views_cannot_import_other_context_models" in enabled: + findings.extend(_views_foreign_models(store, config)) + if "react_feature_may_only_call_own_or_shared_api" in enabled: + findings.extend(_react_own_api(store, config)) + if "serializers_are_the_only_published_contract" in enabled: + findings.extend(_contract_drift(store, config)) + if "no_queryset_in_serializer" in enabled: + findings.extend(_queryset_in_serializer(store)) + if "celery_tasks_must_be_idempotent_on_model_pk" in enabled or "async_tasks_must_be_idempotent_on_model_pk" in enabled: + findings.extend(_task_idempotency(store, changed_ids)) + + for f in findings: + f.waived = _waived(config, f.rule, f.node_id) + if f.waived: + continue + if f.node_id and f.extra.get("other_context"): + store.upsert_edge( + Edge( + src=f.node_id, + dst=node_id(NodeType.BOUNDED_CONTEXT, f.extra["other_context"]), + type=EdgeType.CROSSES_CONTEXT, + extra={"rule": f.rule, "message": f.message}, + ) + ) + store.conn.commit() + return findings + + +def _views_foreign_models(store: GraphStore, config: LoadpathConfig) -> list[Finding]: + out: list[Finding] = [] + views = {n["id"]: n for n in store.nodes([NodeType.VIEW, NodeType.SERVICE])} + models = {n["id"]: n for n in store.nodes([NodeType.MODEL])} + for edge in store.edges(): + if edge["type"] != EdgeType.QUERIES_MODEL.value: + continue + view = views.get(edge["src"]) + model = models.get(edge["dst"]) or store.get_node(edge["dst"]) + if not view or not model: + continue + vctx = view.get("context") + mctx = model.get("context") + if not mctx: + qn = model.get("qualified_name") or "" + app = qn.split(".")[0] if "." in qn else None + mctx = config.context_for_django_app(app) if app else None + if vctx and mctx and vctx != mctx: + store.upsert_node( + Node( + id=node_id(NodeType.BOUNDED_CONTEXT, mctx), + type=NodeType.BOUNDED_CONTEXT, + name=mctx, + qualified_name=mctx, + ) + ) + out.append( + Finding( + rule="views_cannot_import_other_context_models", + severity=RuleSeverity.BLOCKER, + message=( + f"{view['name']} ({vctx}) talks to {model.get('qualified_name')} " + f"({mctx}), skipping that context's service layer" + ), + node_id=view["id"], + file_path=view.get("file_path"), + extra={"other_context": mctx, "model": model.get("qualified_name")}, + ) + ) + return out + + +def _react_own_api(store: GraphStore, config: LoadpathConfig) -> list[Finding]: + out: list[Finding] = [] + clients = {n["id"]: n for n in store.nodes([NodeType.API_CLIENT])} + features = {n["id"]: n for n in store.nodes([NodeType.FEATURE_MODULE, NodeType.HOOK, NodeType.PAGE, NodeType.COMPONENT])} + allowed_by_context: dict[str, set[str]] = {} + for name, ctx in config.contexts.items(): + allowed_by_context[name] = set() + for spec in ctx.public_api: + _, path = parse_public_api(spec) + allowed_by_context[name].add(path) + allowed_by_context[name].add(normalize_url_template(path)) + + for edge in store.edges(): + if edge["type"] not in {EdgeType.CALLS.value, EdgeType.CONSUMED_BY_CLIENT.value}: + continue + src = features.get(edge["src"]) + dst = clients.get(edge["dst"]) + if not src or not dst: + continue + ctx = src.get("context") + if not ctx or config.is_shared_react(src.get("file_path") or ""): + continue + tmpl = normalize_url_template(dst.get("name") or "") + allowed = allowed_by_context.get(ctx) or set() + if not allowed: + continue + if any(_path_belongs(tmpl, a) for a in allowed): + continue + # calling another context's public API? + other = None + for oname, paths in allowed_by_context.items(): + if oname != ctx and any(_path_belongs(tmpl, a) for a in paths): + other = oname + break + if other: + out.append( + Finding( + rule="react_feature_may_only_call_own_or_shared_api", + severity=RuleSeverity.BLOCKER, + message=( + f"React feature '{ctx}' called {tmpl} (owned by {other}) " + f"from {src.get('file_path')}" + ), + node_id=src["id"], + file_path=src.get("file_path"), + extra={"other_context": other, "url": tmpl}, + ) + ) + return out + + +def _path_belongs(url: str, allowed: str) -> bool: + u = normalize_url_template(url) + a = django_route_to_template(allowed) + if u == a: + return True + u_base = u.rstrip("/").rsplit("/{id}", 1)[0] + a_base = a.rstrip("/").rsplit("/{id}", 1)[0] + return u_base == a_base or u.startswith(a_base + "/") + + +def _contract_drift(store: GraphStore, config: LoadpathConfig) -> list[Finding]: + out: list[Finding] = [] + # If a serializer field has no matching zod field on a MATCHES_SCHEMA partner, warn + schemas = {n["id"]: n for n in store.nodes([NodeType.FORM_SCHEMA])} + fields = {n["id"]: n for n in store.nodes([NodeType.SERIALIZER_FIELD])} + serializers = {n["id"]: n for n in store.nodes([NodeType.SERIALIZER])} + matched_serializers: dict[str, list[dict]] = {} + for edge in store.edges(): + if edge["type"] != EdgeType.MATCHES_SCHEMA.value: + continue + ser = serializers.get(edge["src"]) + schema = schemas.get(edge["dst"]) + if ser and schema: + matched_serializers.setdefault(ser["id"], []).append(schema) + for ser_id, schema_list in matched_serializers.items(): + ser = serializers[ser_id] + ser_field_names = { + f["name"] + for f in fields.values() + if f["qualified_name"].startswith(ser["qualified_name"] + ".") + } + for schema in schema_list: + zod = set((schema.get("extra") or {}).get("fields") or []) + extra_in_react = zod - ser_field_names + missing_in_react = ser_field_names - zod + if extra_in_react: + out.append( + Finding( + rule="serializers_are_the_only_published_contract", + severity=RuleSeverity.BLOCKER, + message=( + f"Serializer field removed or missing; React form {schema['name']} " + f"still posts {sorted(extra_in_react)}" + ), + node_id=schema["id"], + file_path=schema.get("file_path"), + extra={"fields": sorted(extra_in_react), "serializer": ser["name"]}, + ) + ) + if missing_in_react and extra_in_react: + # already covered; extra missing is a warning unless changed + pass + return out + + +def _queryset_in_serializer(store: GraphStore) -> list[Finding]: + out = [] + for ser in store.nodes([NodeType.SERIALIZER]): + if (ser.get("extra") or {}).get("queryset_in_serializer"): + out.append( + Finding( + rule="no_queryset_in_serializer", + severity=RuleSeverity.BLOCKER, + message=f"Serializer {ser['name']} appears to run a queryset", + node_id=ser["id"], + file_path=ser.get("file_path"), + ) + ) + return out + + +def _task_idempotency(store: GraphStore, changed_ids: set[str] | None) -> list[Finding]: + out = [] + for task in store.nodes([NodeType.TASK]): + extra = task.get("extra") or {} + if extra.get("referenced") and extra.get("looks_idempotent_on_pk") is None: + continue + if extra.get("looks_idempotent_on_pk") is False or ( + extra.get("looks_idempotent_on_pk") is None and extra.get("args") + ): + if extra.get("looks_idempotent_on_pk"): + continue + args = extra.get("args") or [] + if not args: + continue + if extra.get("looks_idempotent_on_pk"): + continue + if changed_ids and task["id"] not in changed_ids: + # still report if task is in radius — caller filters + pass + if extra.get("looks_idempotent_on_pk") is True: + continue + broker = extra.get("broker") or "task" + label = {"celery": "Celery", "dramatiq": "Dramatiq"}.get(broker, "Async") + out.append( + Finding( + rule="celery_tasks_must_be_idempotent_on_model_pk", + severity=RuleSeverity.WARNING, + message=( + f"{label} task {task['name']} args {args} do not look like a model pk; " + "tasks must be idempotent on model pk" + ), + node_id=task["id"], + file_path=task.get("file_path"), + extra={"args": args, "broker": broker}, + ) + ) + return out diff --git a/src/loadpath/architecture/snapshot.py b/src/loadpath/architecture/snapshot.py new file mode 100644 index 0000000..6b63ca8 --- /dev/null +++ b/src/loadpath/architecture/snapshot.py @@ -0,0 +1,108 @@ +"""Index → architecture snapshot used by review, CLI, and the app.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from loadpath.architecture.rules import evaluate +from loadpath.config import LoadpathConfig, load_config +from loadpath.graph.store import GraphStore +from loadpath.index import default_db_path +from loadpath.types import NodeType + +ARCHITECTURE_NODE_TYPES = { + NodeType.BOUNDED_CONTEXT.value, + NodeType.APP.value, + NodeType.ROUTE.value, + NodeType.VIEW.value, + NodeType.SERIALIZER.value, + NodeType.MODEL.value, + NodeType.TASK.value, + NodeType.MANAGEMENT_COMMAND.value, + NodeType.SIGNAL.value, + NodeType.RECEIVER.value, + NodeType.OPENAPI_PATH.value, + NodeType.PAGE.value, + NodeType.FEATURE_MODULE.value, + NodeType.HOOK.value, + NodeType.API_CLIENT.value, + NodeType.FORM_SCHEMA.value, +} + + +def summarize_index(store: GraphStore, config: LoadpathConfig) -> dict[str, Any]: + findings = [f.to_dict() for f in evaluate(store, config)] + residuals = [line for line in (store.get_meta("residuals") or "").splitlines() if line] + contexts = { + name: { + "name": name, + "django_apps": ctx.django_apps, + "react": ctx.react, + "public_api": ctx.public_api, + "owners": ctx.owners, + } + for name, ctx in config.contexts.items() + } + return { + "ok": True, + "indexed": True, + "repo_root": store.get_meta("repo_root") or str(config.repo_root), + "db": str(store.db_path), + "indexed_at": store.get_meta("indexed_at"), + "incremental": store.get_meta("incremental") == "1", + "counts": store.counts(), + "type_counts": store.type_counts(), + "file_count": store.file_count(), + "contexts": contexts, + "rules": list(config.rules), + "findings": findings, + "residuals": residuals[:40], + "has_config": (config.repo_root / "loadpath.yml").is_file(), + } + + +def architecture_graph(store: GraphStore) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + nodes = [n for n in store.nodes() if n["type"] in ARCHITECTURE_NODE_TYPES] + ids = {n["id"] for n in nodes} + edges = [e for e in store.edges() if e["src"] in ids and e["dst"] in ids] + return nodes, edges + + +def architecture_report(repo_root: Path, db_path: Path | None = None) -> dict[str, Any]: + repo_root = repo_root.resolve() + db = db_path or default_db_path(repo_root) + if not db.is_file(): + cfg = load_config(repo_root) + return { + "ok": False, + "indexed": False, + "repo_root": str(repo_root), + "db": str(db), + "has_config": (repo_root / "loadpath.yml").is_file(), + "contexts": { + name: { + "name": name, + "django_apps": ctx.django_apps, + "react": ctx.react, + "public_api": ctx.public_api, + "owners": ctx.owners, + } + for name, ctx in cfg.contexts.items() + }, + "rules": list(cfg.rules), + "counts": {"nodes": 0, "edges": 0}, + "type_counts": {}, + "findings": [], + "residuals": [], + "nodes": [], + "edges": [], + } + store = GraphStore(db) + config = load_config(repo_root) + summary = summarize_index(store, config) + nodes, edges = architecture_graph(store) + summary["nodes"] = nodes + summary["edges"] = edges + store.close() + return summary diff --git a/src/loadpath/cli.py b/src/loadpath/cli.py new file mode 100644 index 0000000..456386b --- /dev/null +++ b/src/loadpath/cli.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.markdown import Markdown + +from loadpath import __version__ +from loadpath.architecture.snapshot import architecture_report +from loadpath.index import default_db_path, index_repo +from loadpath.review.engine import run_review +from loadpath.review.render import render_html, render_markdown + +app = typer.Typer(add_completion=False, no_args_is_help=True, help="Loadpath — Django+React PR load-path reviewer.") +console = Console() + + +@app.callback() +def _version(version: bool = typer.Option(False, "--version", help="Show version")) -> None: + if version: + console.print(__version__) + raise typer.Exit() + + +@app.command() +def index( + repo: Path = typer.Argument(Path("."), exists=True, file_okay=False), + full: bool = typer.Option(False, "--full", help="Re-extract every file"), +) -> None: + """Index a Django + React monorepo into a SQLite graph.""" + from loadpath.architecture.snapshot import summarize_index + from loadpath.config import load_config + from loadpath.settings import register_workspace + + store = index_repo(repo, incremental=not full) + register_workspace(repo) + summary = summarize_index(store, load_config(repo)) + counts = summary["counts"] + console.print(f"Indexed {counts['nodes']} nodes / {counts['edges']} edges → {default_db_path(repo)}") + contexts = ", ".join(summary["contexts"]) or "none" + console.print(f"Contexts: {contexts}") + findings = [f for f in summary["findings"] if not f.get("waived")] + if findings: + console.print(f"Architecture findings: {len(findings)}") + store.close() + + +@app.command() +def review( + repo: Path = typer.Argument(Path("."), exists=True, file_okay=False), + base: str = typer.Option("origin/main", "--base", "-b"), + head: Optional[str] = typer.Option(None, "--head"), + format: str = typer.Option("markdown", "--format", "-f", help="markdown|json|html"), + out: Optional[Path] = typer.Option(None, "--out", "-o"), + reindex: bool = typer.Option(True, "--reindex/--no-reindex", help="Refresh the index before walking the diff"), + full: bool = typer.Option(False, "--full", help="Full reindex instead of incremental"), +) -> None: + """Review a git range as clustered load paths + confidence brief.""" + payload = run_review(repo, base=base, head=head, reindex=reindex, incremental=not full) + if format == "json": + import json + + text = json.dumps(payload, indent=2, default=str) + elif format == "html": + text = render_html(payload) + else: + text = render_markdown(payload) + console.print(Markdown(text)) + if out is None: + return + if out: + out.write_text(text, encoding="utf-8") + console.print(f"Wrote {out}") + elif format != "markdown": + console.print(text) + + +@app.command() +def architecture( + repo: Path = typer.Argument(Path("."), exists=True, file_okay=False), +) -> None: + """Show the indexed architecture (contexts, rules, type counts). Index first if empty.""" + report = architecture_report(repo) + if not report["indexed"]: + console.print("No index yet. Run `loadpath index` first.") + raise typer.Exit(code=1) + counts = report["counts"] + console.print(f"{counts['nodes']} nodes / {counts['edges']} edges") + console.print("Contexts: " + (", ".join(report["contexts"]) or "none")) + for name, ctx in (report.get("contexts") or {}).items(): + owners = ", ".join(ctx.get("owners") or []) or "—" + console.print(f" {name} apps={','.join(ctx.get('django_apps') or [])} owners={owners}") + active = [f for f in report.get("findings") or [] if not f.get("waived")] + console.print(f"Rules: {len(report.get('rules') or [])} enabled · {len(active)} findings") + for finding in active[:12]: + console.print(f" [{finding['severity']}] {finding['rule']}: {finding['message']}") + + +@app.command() +def serve( + host: str = typer.Option("127.0.0.1", "--host"), + port: int = typer.Option(7345, "--port"), + open_browser: bool = typer.Option(True, "--open/--no-open"), +) -> None: + """Start the Loadpath app (API + UI).""" + from loadpath.server.app import serve as run_server + + run_server(host=host, port=port, open_browser=open_browser) + + +if __name__ == "__main__": + app() diff --git a/src/loadpath/config.py b/src/loadpath/config.py new file mode 100644 index 0000000..a6e9c6e --- /dev/null +++ b/src/loadpath/config.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +DEFAULT_RULES = [ + "views_cannot_import_other_context_models", + "react_feature_may_only_call_own_or_shared_api", + "serializers_are_the_only_published_contract", + "no_queryset_in_serializer", + "celery_tasks_must_be_idempotent_on_model_pk", +] + + +@dataclass +class ContextDef: + name: str + django_apps: list[str] = field(default_factory=list) + react: list[str] = field(default_factory=list) + public_api: list[str] = field(default_factory=list) + owners: list[str] = field(default_factory=list) + + +@dataclass +class Waiver: + rule: str + node: str | None = None + reason: str = "" + + +@dataclass +class LoadpathConfig: + repo_root: Path + contexts: dict[str, ContextDef] = field(default_factory=dict) + django_layers: list[str] = field(default_factory=lambda: ["route", "view", "service", "model"]) + react_layers: list[str] = field( + default_factory=lambda: ["route", "page", "feature", "shared"] + ) + rules: list[str] = field(default_factory=lambda: list(DEFAULT_RULES)) + waivers: list[Waiver] = field(default_factory=list) + django_root: str = "backend" + react_root: str = "frontend/src" + openapi_paths: list[str] = field(default_factory=list) + generated_client_globs: list[str] = field( + default_factory=lambda: [ + "**/generated/**/*.{ts,tsx,js}", + "**/*openapi*.{ts,js}", + "**/orval/**/*.{ts,js}", + ] + ) + extra: dict[str, Any] = field(default_factory=dict) + boot_django: bool = False + + def context_for_django_app(self, app: str) -> str | None: + for name, ctx in self.contexts.items(): + if app in ctx.django_apps: + return name + return None + + def context_for_react_path(self, rel_path: str) -> str | None: + normalized = rel_path.replace("\\", "/") + for name, ctx in self.contexts.items(): + for prefix in ctx.react: + if normalized.startswith(prefix.rstrip("/") + "/") or normalized.startswith( + prefix.rstrip("/") + ): + return name + # also match when react_root is prepended in stored paths + if prefix.rstrip("/") in normalized: + # feature folder name match + feature = prefix.rstrip("/").split("/")[-1] + if f"/features/{feature}/" in f"/{normalized}/": + return name + return None + + def is_shared_react(self, rel_path: str) -> bool: + n = rel_path.replace("\\", "/") + return "/shared/" in f"/{n}/" or n.startswith("shared/") + + def owners_for_context(self, context: str | None) -> list[str]: + if not context: + return [] + ctx = self.contexts.get(context) + return list(ctx.owners) if ctx else [] + + +def find_config(start: Path) -> Path | None: + cur = start.resolve() + for candidate in [cur, *cur.parents]: + path = candidate / "loadpath.yml" + if path.is_file(): + return path + return None + + +def load_config(repo_root: Path, config_path: Path | None = None) -> LoadpathConfig: + path = config_path or find_config(repo_root) or (repo_root / "loadpath.yml") + if not path.is_file(): + return LoadpathConfig(repo_root=repo_root.resolve()) + + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + contexts: dict[str, ContextDef] = {} + for name, data in (raw.get("contexts") or {}).items(): + data = data or {} + contexts[name] = ContextDef( + name=name, + django_apps=list(data.get("django_apps") or []), + react=list(data.get("react") or []), + public_api=list(data.get("public_api") or []), + owners=list(data.get("owners") or []), + ) + + layers = raw.get("layers") or {} + waivers = [ + Waiver(rule=w.get("rule", ""), node=w.get("node"), reason=w.get("reason", "")) + for w in (raw.get("waivers") or []) + ] + return LoadpathConfig( + repo_root=repo_root.resolve(), + contexts=contexts, + django_layers=list(layers.get("django") or ["route", "view", "service", "model"]), + react_layers=list(layers.get("react") or ["route", "page", "feature", "shared"]), + rules=list(raw.get("rules") or DEFAULT_RULES), + waivers=waivers, + django_root=raw.get("django_root", "backend"), + react_root=raw.get("react_root", "frontend/src"), + openapi_paths=list(raw.get("openapi_paths") or []), + generated_client_globs=list( + raw.get("generated_client_globs") + or [ + "**/generated/**/*.{ts,tsx,js}", + "**/*openapi*.{ts,js}", + "**/orval/**/*.{ts,js}", + ] + ), + extra=raw, + boot_django=bool(raw.get("boot_django", False)), + ) diff --git a/src/loadpath/extractors/__init__.py b/src/loadpath/extractors/__init__.py new file mode 100644 index 0000000..1eaca3e --- /dev/null +++ b/src/loadpath/extractors/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from loadpath.extractors.django import extract_django_file, extract_django_paths +from loadpath.extractors.react import extract_react_file + +__all__ = ["extract_django_file", "extract_django_paths", "extract_react_file"] diff --git a/src/loadpath/extractors/django.py b/src/loadpath/extractors/django.py new file mode 100644 index 0000000..742102f --- /dev/null +++ b/src/loadpath/extractors/django.py @@ -0,0 +1,984 @@ +from __future__ import annotations + +import ast +import re +from pathlib import Path +from typing import Iterable + +from loadpath.config import LoadpathConfig +from loadpath.types import Edge, EdgeType, ExtractedGraph, Node, NodeType, node_id + +DJANGO_VIEW_BASES = { + "View", + "APIView", + "GenericAPIView", + "ViewSet", + "GenericViewSet", + "ModelViewSet", + "ReadOnlyModelViewSet", + "ListAPIView", + "RetrieveAPIView", + "CreateAPIView", + "UpdateAPIView", + "DestroyAPIView", + "ListCreateAPIView", + "RetrieveUpdateAPIView", + "RetrieveUpdateDestroyAPIView", + "TemplateView", + "FormView", + "RedirectView", +} + +SERIALIZER_BASES = {"Serializer", "ModelSerializer", "HyperlinkedModelSerializer", "ListSerializer"} +MODEL_BASES = {"Model"} +ADMIN_BASES = {"ModelAdmin", "StackedInline", "TabularInline"} +CELERY_DECORATORS = {"shared_task", "task", "periodic_task"} +DRAMATIQ_DECORATORS = {"actor"} +TASK_DECORATORS = CELERY_DECORATORS | DRAMATIQ_DECORATORS +CELERY_ENQUEUE = {"delay", "apply_async"} +CELERY_SIGNATURE = {"s", "si"} +CELERY_CANVAS = {"group", "chain", "chord", "signature"} +DRAMATIQ_ENQUEUE = {"send", "send_with_options"} +CELERY_TASK_BASES = {"Task"} +DRAMATIQ_TASK_BASES = {"GenericActor"} +NINJA_HTTP = {"get", "post", "put", "patch", "delete", "api_operation"} +REL_FIELD_TYPES = { + "ForeignKey", + "OneToOneField", + "ManyToManyField", + "GenericForeignKey", + "GenericRelation", +} +SIGNAL_NAMES = { + "pre_save", + "post_save", + "pre_delete", + "post_delete", + "m2m_changed", + "pre_init", + "post_init", + "class_prepared", +} +DESTRUCTIVE_MIGRATION_OPS = {"DeleteModel", "RemoveField", "AlterField", "RunPython", "RenameField"} +ON_DELETE_ATTRS = {"CASCADE", "PROTECT", "RESTRICT", "SET_NULL", "SET_DEFAULT", "DO_NOTHING", "SET"} + + +def _name(node: ast.AST | None) -> str | None: + if node is None: + return None + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + parent = _name(node.value) + return f"{parent}.{node.attr}" if parent else node.attr + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _const_str(node: ast.AST | None) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.JoinedStr): + parts: list[str] = [] + for v in node.values: + if isinstance(v, ast.Constant) and isinstance(v.value, str): + parts.append(v.value) + else: + parts.append("{}") + return "".join(parts) + return None + + +def _list_names(node: ast.AST | None) -> list[str]: + if isinstance(node, (ast.List, ast.Tuple)): + names = [] + for elt in node.elts: + n = _name(elt) + if n: + names.append(n.split(".")[-1]) + return names + n = _name(node) + return [n.split(".")[-1]] if n else [] + + +def _bases(node: ast.ClassDef) -> list[str]: + return [b for b in (_name(base) for base in node.bases) if b] + + +def _has_base(node: ast.ClassDef, names: set[str]) -> bool: + return any((b.split(".")[-1] in names) for b in _bases(node)) + + +def _decorator_names(node: ast.FunctionDef | ast.ClassDef | ast.AsyncFunctionDef) -> list[str]: + out = [] + for dec in node.decorator_list: + if isinstance(dec, ast.Call): + n = _name(dec.func) + else: + n = _name(dec) + if n: + out.append(n) + return out + + +def _kw(call: ast.Call, key: str) -> ast.AST | None: + for kw in call.keywords: + if kw.arg == key: + return kw.value + return None + + +def _app_from_path(rel: str) -> str | None: + parts = Path(rel).parts + # backend/billing/models.py → billing + if "migrations" in parts: + idx = parts.index("migrations") + if idx > 0: + return parts[idx - 1] + for i, part in enumerate(parts): + if part in {"models.py", "views.py", "serializers.py", "urls.py", "signals.py", "tasks.py", "admin.py", "apps.py"}: + return parts[i - 1] if i > 0 else None + if part == "management" and i > 0: + return parts[i - 1] + if len(parts) >= 2 and parts[-1].endswith(".py"): + return parts[-2] + return None + + +def _module_qual(rel: str) -> str: + p = Path(rel) + if p.suffix == ".py": + p = p.with_suffix("") + return ".".join(p.parts) + + +class DjangoExtractor(ast.NodeVisitor): + def __init__(self, rel_path: str, source: str, config: LoadpathConfig) -> None: + self.rel_path = rel_path.replace("\\", "/") + self.source = source + self.config = config + self.app = _app_from_path(self.rel_path) or "unknown" + self.context = config.context_for_django_app(self.app) + self.graph = ExtractedGraph() + self.imports: dict[str, str] = {} # local name → module.qual + self.from_imports: dict[str, str] = {} + self.class_stack: list[str] = [] + + def add_node(self, ntype: NodeType, name: str, qname: str, lineno: int, extra: dict | None = None) -> Node: + node = Node( + id=node_id(ntype, qname), + type=ntype, + name=name, + qualified_name=qname, + file_path=self.rel_path, + start_line=lineno, + context=self.context, + extra=extra or {}, + ) + self.graph.nodes.append(node) + return node + + def add_edge(self, src: str, dst: str, etype: EdgeType, confidence: float = 1.0, extra: dict | None = None) -> None: + self.graph.edges.append(Edge(src=src, dst=dst, type=etype, confidence=confidence, extra=extra or {})) + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self.imports[alias.asname or alias.name.split(".")[-1]] = alias.name + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + mod = node.module or "" + for alias in node.names: + local = alias.asname or alias.name + self.from_imports[local] = f"{mod}.{alias.name}" if mod else alias.name + self.generic_visit(node) + + def _resolve(self, name: str) -> str: + if name in self.from_imports: + return self.from_imports[name] + if name in self.imports: + return self.imports[name] + root = name.split(".")[0] + if root in self.from_imports: + return self.from_imports[root] + name[len(root) :] + if root in self.imports: + return self.imports[root] + name[len(root) :] + return name + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.class_stack.append(node.name) + if _has_base(node, MODEL_BASES): + self._model(node) + elif _has_base(node, SERIALIZER_BASES): + self._serializer(node) + elif _has_base(node, DJANGO_VIEW_BASES) or node.name.endswith(("View", "ViewSet")): + self._view(node) + elif any(b.split(".")[-1] in {"BaseCommand", "AppCommand", "LabelCommand"} for b in _bases(node)): + self.add_node( + NodeType.MANAGEMENT_COMMAND, + node.name if node.name != "Command" else Path(self.rel_path).stem, + f"{self.app}.{Path(self.rel_path).stem}", + node.lineno, + {"app": self.app}, + ) + elif self._task_class(node): + pass + elif _has_base(node, ADMIN_BASES) or node.name.endswith("Admin"): + self._admin(node) + elif any(x.endswith("Config") for x in _bases(node)) or node.name.endswith("Config"): + self._app_config(node) + elif "Service" in node.name or "UseCase" in node.name: + self._service_class(node) + self.generic_visit(node) + self.class_stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._maybe_task(node) + self._maybe_receiver(node) + self._maybe_command(node) + self._maybe_test(node) + self._maybe_service_fn(node) + self._maybe_fbv(node) + self._maybe_ninja(node) + self.generic_visit(node) + + visit_AsyncFunctionDef = visit_FunctionDef # type: ignore[assignment] + + def visit_Call(self, node: ast.Call) -> None: + fname = _name(node.func) or "" + short = fname.split(".")[-1] + if short in {"path", "re_path", "url"}: + self._url_path(node) + elif short == "register" and "router" in fname.lower(): + self._router_register(node) + elif short == "include": + pass + elif short == "get_model": + self._get_model(node) + elif short in CELERY_ENQUEUE: + prefix = fname.rsplit(".", 1)[0] if "." in fname else "" + if prefix and prefix.split(".")[-1] not in CELERY_CANVAS: + self._enqueue(node, fname, broker="celery") + elif short in CELERY_SIGNATURE and self._looks_like_celery(fname): + self._enqueue(node, fname, broker="celery") + elif short in CELERY_CANVAS and self._looks_like_celery(fname): + self.graph.residuals.append(f"Celery canvas {fname}() at {self.rel_path}:{node.lineno}") + elif short == "send_task": + self._send_task(node) + elif short in DRAMATIQ_ENQUEUE and self._looks_like_dramatiq_send(fname): + self._enqueue(node, fname, broker="dramatiq") + elif short == "on_commit": + self.graph.residuals.append( + f"transaction.on_commit() at {self.rel_path}:{node.lineno} — async work may be hidden in a lambda" + ) + self._enqueue_from_on_commit(node) + elif short in {"raw", "execute"} or (short == "extra" and _kw(node, "where")): + self.graph.residuals.append(f"Raw SQL ({fname}) in {self.rel_path}:{node.lineno}") + elif short in {"select_related", "prefetch_related"}: + pass + elif short == "connect" and any(s in fname for s in SIGNAL_NAMES | {"signal"}): + self._signal_connect(node, fname) + elif short == "reverse": + self._reverse(node) + self.generic_visit(node) + + def visit_Assign(self, node: ast.Assign) -> None: + # urlpatterns = [...] + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "urlpatterns" and isinstance(node.value, (ast.List, ast.Tuple)): + for elt in node.value.elts: + if isinstance(elt, ast.Call): + self.visit_Call(elt) + if isinstance(target, ast.Name) and target.id in {"CELERY_BEAT_SCHEDULE", "beat_schedule"}: + self._beat_schedule(node.value) + self.generic_visit(node) + + def _model(self, node: ast.ClassDef) -> None: + qname = f"{self.app}.{node.name}" + model = self.add_node(NodeType.MODEL, node.name, qname, node.lineno, {"app": self.app}) + app_node = self.add_node(NodeType.APP, self.app, self.app, 1) + self.add_edge(model.id, app_node.id, EdgeType.BELONGS_TO) + for stmt in node.body: + if isinstance(stmt, ast.Assign) and stmt.targets and isinstance(stmt.targets[0], ast.Name): + fname = stmt.targets[0].id + if fname.startswith("_"): + continue + field_q = f"{qname}.{fname}" + extra: dict = {"app": self.app} + rel_to = None + on_delete = None + if isinstance(stmt.value, ast.Call): + call_name = _name(stmt.value.func) or "" + extra["field_type"] = call_name.split(".")[-1] + extra["relation"] = extra["field_type"] in REL_FIELD_TYPES + to_arg = stmt.value.args[0] if stmt.value.args else _kw(stmt.value, "to") + rel_to = _const_str(to_arg) or _name(to_arg) + if isinstance(to_arg, ast.Constant) and isinstance(to_arg.value, str): + extra["string_ref"] = True + self.graph.residuals.append( + f'string model ref {to_arg.value} on {qname}.{fname} ({self.rel_path}:{stmt.lineno})' + ) + od = _kw(stmt.value, "on_delete") + on_delete = _name(od) + extra["on_delete"] = on_delete.split(".")[-1] if on_delete else None + extra["related_name"] = _const_str(_kw(stmt.value, "related_name")) + field_node = self.add_node(NodeType.FIELD, fname, field_q, stmt.lineno, extra) + self.add_edge(model.id, field_node.id, EdgeType.HAS_FIELD) + if rel_to: + target = rel_to if "." in rel_to else f"{self.app}.{rel_to.split('.')[-1]}" + rel_id = node_id(NodeType.MODEL, target) + self.add_edge( + field_node.id, + rel_id, + EdgeType.RELATES_TO, + extra={"on_delete": extra.get("on_delete")}, + ) + if extra.get("on_delete") == "CASCADE": + self.add_edge(model.id, rel_id, EdgeType.RELATES_TO, extra={"cascade": True}) + + def _serializer(self, node: ast.ClassDef) -> None: + qname = f"{self.app}.{node.name}" + ser = self.add_node(NodeType.SERIALIZER, node.name, qname, node.lineno, {"app": self.app}) + meta_model = None + meta_fields: list[str] | None = None + meta_exclude: list[str] | None = None + declared: list[tuple[str, int]] = [] + queryset_in_serializer = False + for stmt in node.body: + if isinstance(stmt, ast.ClassDef) and stmt.name == "Meta": + for m in stmt.body: + if isinstance(m, ast.Assign) and m.targets and isinstance(m.targets[0], ast.Name): + key = m.targets[0].id + if key == "model": + meta_model = _name(m.value) + elif key == "fields": + if isinstance(m.value, ast.Constant) and m.value.value == "__all__": + meta_fields = ["__all__"] + else: + meta_fields = [ + elt.value + for elt in (m.value.elts if isinstance(m.value, (ast.List, ast.Tuple)) else []) + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ] + elif key == "exclude": + meta_exclude = [ + elt.value + for elt in (m.value.elts if isinstance(m.value, (ast.List, ast.Tuple)) else []) + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ] + if isinstance(stmt, ast.Assign) and stmt.targets and isinstance(stmt.targets[0], ast.Name): + fname = stmt.targets[0].id + if not fname.startswith("_") and fname[0].islower(): + declared.append((fname, stmt.lineno)) + if isinstance(stmt, ast.FunctionDef) and "queryset" in ast.dump(stmt): + queryset_in_serializer = True + body = self._slice(node) + if queryset_in_serializer or ".objects." in body or "objects.filter" in body: + ser.extra["queryset_in_serializer"] = True + fields = declared[:] + if meta_fields and meta_fields != ["__all__"]: + existing = {n for n, _ in fields} + for f in meta_fields: + if f not in existing: + fields.append((f, node.lineno)) + for fname, lineno in fields: + fq = f"{qname}.{fname}" + fn = self.add_node(NodeType.SERIALIZER_FIELD, fname, fq, lineno, {"app": self.app}) + self.add_edge(ser.id, fn.id, EdgeType.HAS_FIELD) + if meta_model: + model_q = meta_model if "." in meta_model else f"{self.app}.{meta_model.split('.')[-1]}" + self.add_edge(fn.id, node_id(NodeType.FIELD, f"{model_q}.{fname}"), EdgeType.SERIALIZES, confidence=0.85) + if meta_model: + model_q = meta_model if "." in meta_model else f"{self.app}.{meta_model.split('.')[-1]}" + self.add_edge(ser.id, node_id(NodeType.MODEL, model_q), EdgeType.SERIALIZES) + if meta_exclude: + ser.extra["exclude"] = meta_exclude + + def _view(self, node: ast.ClassDef) -> None: + qname = f"{self.app}.{node.name}" + extra: dict = {"app": self.app, "bases": _bases(node)} + serializer_class = None + permissions: list[str] = [] + queryset_model = None + dynamic_serializer = False + for stmt in node.body: + if isinstance(stmt, ast.Assign) and stmt.targets and isinstance(stmt.targets[0], ast.Name): + key = stmt.targets[0].id + if key == "serializer_class": + serializer_class = _name(stmt.value) + elif key == "permission_classes": + permissions = _list_names(stmt.value) + elif key == "throttle_classes": + extra["throttles"] = _list_names(stmt.value) + elif key == "queryset": + dumped = ast.dump(stmt.value) + m = re.search(r"id='([A-Z][A-Za-z0-9_]+)'", dumped) + if m: + queryset_model = m.group(1) + elif key == "filterset_class": + extra["filterset"] = _name(stmt.value) + elif key == "authentication_classes": + extra["authentication"] = _list_names(stmt.value) + elif key == "pagination_class": + extra["pagination"] = _name(stmt.value) + if isinstance(stmt, ast.FunctionDef) and stmt.name == "get_queryset": + extra["get_queryset"] = True + dumped = ast.dump(stmt) + m = re.search(r"id='([A-Z][A-Za-z0-9_]+)'", dumped) + if m and not queryset_model: + queryset_model = m.group(1) + if isinstance(stmt, ast.FunctionDef) and stmt.name == "get_serializer_class": + dynamic_serializer = True + extra["get_serializer_class"] = True + self.graph.residuals.append( + f"Dynamic get_serializer_class on {qname} ({self.rel_path}:{stmt.lineno})" + ) + if isinstance(stmt, ast.FunctionDef) and stmt.name in { + "list", + "create", + "retrieve", + "update", + "partial_update", + "destroy", + }: + action_q = f"{qname}.{stmt.name}" + action = self.add_node( + NodeType.VIEWSET_ACTION, stmt.name, action_q, stmt.lineno, {"app": self.app} + ) + self.add_edge(node_id(NodeType.VIEW, qname), action.id, EdgeType.CALLS) + extra["permissions"] = permissions + view = self.add_node(NodeType.VIEW, node.name, qname, node.lineno, extra) + if serializer_class: + ser_q = ( + serializer_class + if "." in serializer_class and not serializer_class.startswith("serializers") + else f"{self.app}.{serializer_class.split('.')[-1]}" + ) + self.add_edge(view.id, node_id(NodeType.SERIALIZER, ser_q), EdgeType.USES_SERIALIZER) + for perm in permissions: + pid = node_id(NodeType.PERMISSION, perm) + self.graph.nodes.append( + Node(id=pid, type=NodeType.PERMISSION, name=perm, qualified_name=perm, extra={"from_view": qname}) + ) + self.add_edge(view.id, pid, EdgeType.HAS_PERMISSION) + if queryset_model: + self.add_edge( + view.id, + node_id(NodeType.MODEL, f"{self.app}.{queryset_model}"), + EdgeType.QUERIES_MODEL, + ) + if dynamic_serializer: + extra["dynamic"] = True + # foreign model imports used in this view file + self._note_cross_app_model_imports(view.id) + + def _note_cross_app_model_imports(self, view_id: str) -> None: + for local, target in self.from_imports.items(): + # accounts.models.UserProfile + parts = target.split(".") + if "models" in parts: + idx = parts.index("models") + app = parts[idx - 1] if idx > 0 else None + model = parts[idx + 1] if idx + 1 < len(parts) else local + if app and app != self.app and model[:1].isupper(): + self.add_edge( + view_id, + node_id(NodeType.MODEL, f"{app}.{model}"), + EdgeType.QUERIES_MODEL, + extra={"imported": True, "foreign_app": app}, + ) + + def _admin(self, node: ast.ClassDef) -> None: + qname = f"{self.app}.{node.name}" + self.add_node(NodeType.ADMIN, node.name, qname, node.lineno, {"app": self.app}) + + def _service_class(self, node: ast.ClassDef) -> None: + qname = f"{self.app}.{node.name}" + self.add_node(NodeType.SERVICE, node.name, qname, node.lineno, {"app": self.app}) + + def _maybe_service_fn(self, node: ast.FunctionDef) -> None: + if Path(self.rel_path).name in {"services.py", "use_cases.py", "usecases.py"}: + qname = f"{self.app}.{node.name}" + self.add_node(NodeType.SERVICE, node.name, qname, node.lineno, {"app": self.app}) + + def _app_config(self, node: ast.ClassDef) -> None: + for stmt in node.body: + if isinstance(stmt, ast.FunctionDef) and stmt.name == "ready": + text = self._slice(stmt) + if "import" in text or "connect" in text or "receiver" in text: + self.graph.residuals.append( + f"Signal/import registration in AppConfig.ready() {self.rel_path}:{stmt.lineno}" + ) + + def _maybe_task(self, node: ast.FunctionDef) -> None: + decs = _decorator_names(node) + broker = None + if any("dramatiq" in d or d.split(".")[-1] in DRAMATIQ_DECORATORS for d in decs): + broker = "dramatiq" + elif any("celery" in d or d.split(".")[-1] in CELERY_DECORATORS for d in decs): + broker = "celery" + elif "tasks.py" in self.rel_path or "actors.py" in self.rel_path: + if node.name.endswith("task") or "task" in node.name or "actors.py" in self.rel_path: + broker = "dramatiq" if "actors.py" in self.rel_path else "celery" + if broker is None: + return + qname = f"{self.app}.{node.name}" + args = [a.arg for a in node.args.args if a.arg not in {"self", "cls"}] + extra = { + "app": self.app, + "args": args, + "broker": broker, + "decorators": decs, + } + extra["looks_idempotent_on_pk"] = _looks_idempotent(args) + self.add_node(NodeType.TASK, node.name, qname, node.lineno, extra) + + def _task_class(self, node: ast.ClassDef) -> bool: + bases = _bases(node) + shorts = {b.split(".")[-1] for b in bases} + blob = " ".join(self.imports.values()) + " " + " ".join(self.from_imports.values()) + broker = None + if shorts & DRAMATIQ_TASK_BASES or any("dramatiq" in b.lower() for b in bases): + broker = "dramatiq" + elif shorts & CELERY_TASK_BASES and ( + "celery" in blob.lower() or any("celery" in b.lower() for b in bases) + ): + broker = "celery" + if broker is None: + return False + run = next( + ( + stmt + for stmt in node.body + if isinstance(stmt, ast.FunctionDef) and stmt.name in {"run", "perform"} + ), + None, + ) + args = [a.arg for a in run.args.args if a.arg not in {"self", "cls"}] if run else [] + self.add_node( + NodeType.TASK, + node.name, + f"{self.app}.{node.name}", + node.lineno, + { + "app": self.app, + "args": args, + "broker": broker, + "task_class": True, + "looks_idempotent_on_pk": _looks_idempotent(args), + }, + ) + return True + + def _looks_like_celery(self, fname: str) -> bool: + blob = " ".join(self.imports.values()) + " " + " ".join(self.from_imports.values()) + " " + self.source[:800] + if "celery" in blob.lower() or "tasks.py" in self.rel_path: + return True + root = fname.split(".")[0] + target = self.from_imports.get(root, self.imports.get(root, "")) + return any(part in target for part in ("tasks", "celery", "actors")) + + def _looks_like_dramatiq_send(self, fname: str) -> bool: + blob = " ".join(self.imports.values()) + " " + " ".join(self.from_imports.values()) + " " + self.source[:400] + if "dramatiq" in blob: + return True + root = fname.split(".")[0] + target = self.from_imports.get(root, self.imports.get(root, "")) + return any(part in target for part in ("actors", "tasks", "dramatiq")) + + def _enqueue(self, node: ast.Call, fname: str, broker: str = "celery") -> None: + app, short, qname = self._task_qname(fname) + owner = self.class_stack[-1] if self.class_stack else Path(self.rel_path).stem + owner_type = NodeType.VIEW + if "management/commands" in self.rel_path: + owner_type = NodeType.MANAGEMENT_COMMAND + owner = Path(self.rel_path).stem + elif owner.endswith("Serializer"): + owner_type = NodeType.SERIALIZER + elif "Service" in owner or owner.endswith("UseCase"): + owner_type = NodeType.SERVICE + self.add_edge( + node_id(owner_type, f"{self.app}.{owner}"), + node_id(NodeType.TASK, qname), + EdgeType.ENQUEUES, + confidence=0.9, + extra={"call": fname, "line": node.lineno, "broker": broker}, + ) + self.add_node( + NodeType.TASK, + short, + qname, + node.lineno, + {"referenced": True, "app": app, "broker": broker}, + ) + + def _task_qname(self, fname: str) -> tuple[str, str, str]: + task_name = fname.rsplit(".", 1)[0] if "." in fname else fname + resolved = self._resolve(task_name) + parts = [p for p in resolved.split(".") if p] + short = parts[-1] if parts else task_name.split(".")[-1] + app = self.app + for marker in ("tasks", "actors", "jobs"): + if marker in parts: + idx = parts.index(marker) + if idx > 0: + app = parts[idx - 1] + break + return app, short, f"{app}.{short}" + + def _enqueue_from_on_commit(self, node: ast.Call) -> None: + for arg in list(node.args) + [kw.value for kw in node.keywords]: + for child in ast.walk(arg): + if isinstance(child, ast.Call): + fname = _name(child.func) or "" + short = fname.split(".")[-1] + if short in CELERY_ENQUEUE: + self._enqueue(child, fname, broker="celery") + elif short in DRAMATIQ_ENQUEUE: + self._enqueue(child, fname, broker="dramatiq") + + def _send_task(self, node: ast.Call) -> None: + name = _const_str(node.args[0]) if node.args else None + self.graph.residuals.append( + f'celery.send_task("{name or "?"}") at {self.rel_path}:{node.lineno}' + ) + if not name: + return + short = name.split(".")[-1] + app = name.split(".")[0] if "." in name else self.app + qname = f"{app}.{short}" + owner = self.class_stack[-1] if self.class_stack else Path(self.rel_path).stem + owner_type = NodeType.VIEW + if "management/commands" in self.rel_path: + owner_type = NodeType.MANAGEMENT_COMMAND + owner = Path(self.rel_path).stem + self.add_edge( + node_id(owner_type, f"{self.app}.{owner}"), + node_id(NodeType.TASK, qname), + EdgeType.ENQUEUES, + confidence=0.7, + extra={"call": "send_task", "line": node.lineno, "broker": "celery", "task": name}, + ) + self.add_node( + NodeType.TASK, + short, + qname, + node.lineno, + {"referenced": True, "app": app, "broker": "celery", "via": "send_task"}, + ) + + def _beat_schedule(self, value: ast.AST) -> None: + if not isinstance(value, ast.Dict): + return + for key, val in zip(value.keys, value.values, strict=True): + entry_name = _const_str(key) or _name(key) or "beat" + task_name = None + if isinstance(val, ast.Dict): + for k, v in zip(val.keys, val.values, strict=True): + label = _const_str(k) or _name(k) + if label == "task": + task_name = _const_str(v) or _name(v) + if not task_name: + continue + parts = task_name.split(".") + short = parts[-1] + app = parts[0] if len(parts) > 1 else self.app + qname = f"{app}.{short}" + self.add_node( + NodeType.TASK, + short, + qname, + getattr(value, "lineno", 1), + { + "referenced": True, + "app": app, + "broker": "celery", + "beat": True, + "schedule_name": entry_name, + }, + ) + self.graph.residuals.append( + f"Celery beat '{entry_name}' → {task_name} ({self.rel_path})" + ) + + def _maybe_fbv(self, node: ast.FunctionDef) -> None: + decs = _decorator_names(node) + if not any(d.split(".")[-1] in {"api_view", "csrf_exempt", "login_required", "permission_required", "require_GET", "require_POST"} for d in decs): + return + if node.name in {"get", "post", "put", "patch", "delete", "handle"}: + return + qname = f"{self.app}.{node.name}" + extra = {"app": self.app, "fbv": True, "decorators": decs} + view = self.add_node(NodeType.VIEW, node.name, qname, node.lineno, extra) + self._note_cross_app_model_imports(view.id) + + def _maybe_ninja(self, node: ast.FunctionDef) -> None: + for dec in node.decorator_list: + if not isinstance(dec, ast.Call): + continue + fname = _name(dec.func) or "" + short = fname.split(".")[-1] + if short not in NINJA_HTTP: + continue + if not any(tok in fname.lower() for tok in ("api", "router", "ninja")): + continue + route = _const_str(dec.args[0]) if dec.args else None + qname = f"{self.app}.{node.name}" + view = self.add_node( + NodeType.VIEW, + node.name, + qname, + node.lineno, + {"app": self.app, "ninja": True, "method": short.upper()}, + ) + if route: + rn = self.add_node( + NodeType.ROUTE, + route, + f"{self.app}:{route}", + node.lineno, + {"app": self.app, "route": route, "ninja": True, "method": short.upper()}, + ) + self.add_edge(rn.id, view.id, EdgeType.PUBLISHES_ROUTE) + + def _maybe_receiver(self, node: ast.FunctionDef) -> None: + for dec in node.decorator_list: + dname = _name(dec.func if isinstance(dec, ast.Call) else dec) or "" + if dname.split(".")[-1] != "receiver": + continue + sender = None + signal = None + if isinstance(dec, ast.Call): + if dec.args: + signal = _name(dec.args[0]) + sender_node = _kw(dec, "sender") + sender = _name(sender_node) + qname = f"{self.app}.{node.name}" + extra = {"app": self.app, "signal": signal, "sender": sender} + recv = self.add_node(NodeType.RECEIVER, node.name, qname, node.lineno, extra) + if signal: + sig_id = node_id(NodeType.SIGNAL, signal.split(".")[-1]) + self.graph.nodes.append( + Node( + id=sig_id, + type=NodeType.SIGNAL, + name=signal.split(".")[-1], + qualified_name=signal.split(".")[-1], + ) + ) + self.add_edge(sig_id, recv.id, EdgeType.RECEIVES) + if sender: + model_q = sender if "." in sender else f"{self.app}.{sender.split('.')[-1]}" + self.add_edge(recv.id, node_id(NodeType.MODEL, model_q), EdgeType.EMITS_SIGNAL) + + def _maybe_command(self, node: ast.FunctionDef) -> None: + if "management/commands" in self.rel_path and node.name == "handle": + cmd = Path(self.rel_path).stem + self.add_node(NodeType.MANAGEMENT_COMMAND, cmd, f"{self.app}.{cmd}", node.lineno, {"app": self.app}) + + def _maybe_test(self, node: ast.FunctionDef) -> None: + is_test_file = ( + Path(self.rel_path).name.startswith("test") + or "/tests/" in f"/{self.rel_path}/" + or Path(self.rel_path).name == "tests.py" + ) + if not is_test_file: + return + if not (node.name.startswith("test_") or node.name.startswith("test")): + return + qname = f"{self.app}.{node.name}" + extra = {"app": self.app, "nodeid": f"{self.rel_path}::{node.name}"} + test = self.add_node(NodeType.TEST, node.name, qname, node.lineno, extra) + # crude: referenced class names in the test become tested_by + for child in ast.walk(node): + if isinstance(child, ast.Name) and child.id[:1].isupper(): + for ntype in (NodeType.SERIALIZER, NodeType.VIEW, NodeType.MODEL, NodeType.SERVICE): + self.add_edge( + node_id(ntype, f"{self.app}.{child.id}"), + test.id, + EdgeType.TESTED_BY, + confidence=0.7, + ) + + def _url_path(self, node: ast.Call) -> None: + if not node.args: + return + route = _const_str(node.args[0]) + if route is None: + return + view_name = None + if len(node.args) > 1: + view_expr = node.args[1] + if isinstance(view_expr, ast.Call) and _name(view_expr.func) and "as_view" in (_name(view_expr.func) or ""): + view_name = (_name(view_expr.func) or "").replace(".as_view", "") + # mapping dict for viewsets + if view_expr.args and isinstance(view_expr.args[0], ast.Dict): + for k, v in zip(view_expr.args[0].keys, view_expr.args[0].values, strict=True): + method = _const_str(k) + action = _name(v) + if method and action: + pass + else: + view_name = _name(view_expr) + name = _const_str(_kw(node, "name")) + include_mod = None + if len(node.args) > 1: + view_expr = node.args[1] + if isinstance(view_expr, ast.Call): + fn = _name(view_expr.func) or "" + if fn.split(".")[-1] == "include": + include_mod = _const_str(view_expr.args[0]) if view_expr.args else _name(view_expr.args[0] if view_expr.args else None) + view_name = None + extra = { + "app": self.app, + "route": route, + "url_name": name, + "view": view_name, + "include": include_mod, + } + route_node = self.add_node(NodeType.ROUTE, f"{route}", f"{self.app}:{route}", node.lineno, extra) + if name: + un = self.add_node(NodeType.URL_NAME, name, name, node.lineno, extra) + self.add_edge(route_node.id, un.id, EdgeType.BELONGS_TO) + if view_name: + vq = view_name if "." in view_name else f"{self.app}.{view_name.split('.')[-1]}" + self.add_edge(route_node.id, node_id(NodeType.VIEW, vq), EdgeType.PUBLISHES_ROUTE) + + def _router_register(self, node: ast.Call) -> None: + if len(node.args) < 2: + return + prefix = _const_str(node.args[0]) or "" + viewset = _name(node.args[1]) + basename = _const_str(_kw(node, "basename")) + route = prefix if prefix.startswith("/") else f"/{prefix}" + extra = {"app": self.app, "router": True, "basename": basename, "view": viewset} + route_node = self.add_node( + NodeType.ROUTE, f"{route}", f"{self.app}:{route}", node.lineno, extra + ) + if viewset: + vq = viewset if "." in viewset else f"{self.app}.{viewset.split('.')[-1]}" + self.add_edge(route_node.id, node_id(NodeType.VIEW, vq), EdgeType.PUBLISHES_ROUTE) + + def _get_model(self, node: ast.Call) -> None: + arg = node.args[0] if node.args else None + label = _const_str(arg) + if not label: + if len(node.args) >= 2: + app = _const_str(node.args[0]) + model = _const_str(node.args[1]) + label = f"{app}.{model}" if app and model else None + if label: + self.graph.residuals.append( + f'string model ref apps.get_model("{label}") in {self.rel_path}:{node.lineno}' + ) + self.add_node(NodeType.MODEL, label.split(".")[-1], label, node.lineno, {"string_ref": True}) + + def _signal_connect(self, node: ast.Call, fname: str) -> None: + self.graph.residuals.append(f"signal.connect() at {self.rel_path}:{node.lineno} ({fname})") + + def _reverse(self, node: ast.Call) -> None: + name = _const_str(node.args[0]) if node.args else None + if name: + self.add_node(NodeType.URL_NAME, name, name, node.lineno, {"via": "reverse"}) + + def _slice(self, node: ast.AST) -> str: + try: + return ast.get_source_segment(self.source, node) or "" + except Exception: + return "" + + +def _looks_idempotent(args: list[str]) -> bool: + return any( + a in {"pk", "id", "invoice_id", "object_id", "model_id"} or a.endswith("_id") or a.endswith("_pk") + for a in args + ) + + +def extract_migrations(rel_path: str, source: str, config: LoadpathConfig) -> ExtractedGraph: + graph = ExtractedGraph() + app = _app_from_path(rel_path) or "unknown" + context = config.context_for_django_app(app) + try: + tree = ast.parse(source) + except SyntaxError: + return graph + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + op = _name(node.func) or "" + short = op.split(".")[-1] + if short not in DESTRUCTIVE_MIGRATION_OPS and short not in { + "CreateModel", + "AddField", + "RenameModel", + "AlterUniqueTogether", + }: + continue + args_repr = [] + for a in node.args: + s = _const_str(a) or _name(a) + if s: + args_repr.append(s) + for kw in node.keywords: + if kw.arg in {"name", "model_name"}: + s = _const_str(kw.value) or _name(kw.value) + if s: + args_repr.append(s) + label = f"{short}({', '.join(args_repr[:3])})" + qname = f"{app}.{Path(rel_path).stem}.{label}" + extra = {"op": short, "app": app, "args": args_repr} + n = Node( + id=node_id(NodeType.MIGRATION_OP, qname), + type=NodeType.MIGRATION_OP, + name=label, + qualified_name=qname, + file_path=rel_path, + start_line=node.lineno, + context=context, + extra=extra, + ) + graph.nodes.append(n) + if short in {"DeleteModel", "RemoveField", "RunPython"}: + graph.edges.append( + Edge( + src=n.id, + dst=n.id, + type=EdgeType.DESTRUCTIVE_MIGRATION, + extra={"op": short}, + ) + ) + if short == "RemoveField" and args_repr: + model = args_repr[0] + field = args_repr[1] if len(args_repr) > 1 else "?" + graph.edges.append( + Edge( + src=n.id, + dst=node_id(NodeType.FIELD, f"{app}.{model}.{field}"), + type=EdgeType.DESTRUCTIVE_MIGRATION, + ) + ) + return graph + + +def extract_django_file(rel_path: str, source: str, config: LoadpathConfig) -> ExtractedGraph: + rel = rel_path.replace("\\", "/") + if "/migrations/" in f"/{rel}/" and rel.endswith(".py"): + return extract_migrations(rel, source, config) + try: + tree = ast.parse(source) + except SyntaxError as exc: + g = ExtractedGraph() + g.residuals.append(f"Python syntax error in {rel}: {exc}") + return g + extractor = DjangoExtractor(rel, source, config) + extractor.visit(tree) + return extractor.graph + + +def extract_django_paths(paths: Iterable[tuple[str, str]], config: LoadpathConfig) -> ExtractedGraph: + combined = ExtractedGraph() + for rel, source in paths: + combined.extend(extract_django_file(rel, source, config)) + return combined diff --git a/src/loadpath/extractors/django_boot.py b/src/loadpath/extractors/django_boot.py new file mode 100644 index 0000000..a969356 --- /dev/null +++ b/src/loadpath/extractors/django_boot.py @@ -0,0 +1,132 @@ +"""Optional django.setup() overlay. AST is the default; boot enriches model _meta when settings import.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from loadpath.config import LoadpathConfig +from loadpath.types import Edge, EdgeType, ExtractedGraph, Node, NodeType, node_id + + +def try_boot_models(repo_root: Path, config: LoadpathConfig) -> ExtractedGraph: + graph = ExtractedGraph() + settings_mod = _discover_settings_module(repo_root, config.django_root) + if not settings_mod: + return graph + try: + import django + from django.apps import apps + except ImportError: + graph.residuals.append("django.setup() skipped: Django is not installed (AST graph still used)") + return graph + + prev = os.environ.get("DJANGO_SETTINGS_MODULE") + added: list[str] = [] + try: + root = str(repo_root) + backend = str(repo_root / config.django_root) + for p in (root, backend): + if p not in sys.path: + sys.path.insert(0, p) + added.append(p) + os.environ["DJANGO_SETTINGS_MODULE"] = settings_mod + django.setup() + for model in apps.get_models(): + app = model._meta.app_label + qname = f"{app}.{model.__name__}" + context = config.context_for_django_app(app) + graph.nodes.append( + Node( + id=node_id(NodeType.MODEL, qname), + type=NodeType.MODEL, + name=model.__name__, + qualified_name=qname, + context=context, + extra={ + "app": app, + "db_table": model._meta.db_table, + "booted": True, + }, + ) + ) + for field in model._meta.get_fields(): + fname = getattr(field, "name", None) + if not fname: + continue + extra = { + "app": app, + "field_type": field.__class__.__name__, + "booted": True, + } + if getattr(field, "is_relation", False): + remote = getattr(field, "related_model", None) + on_delete = getattr(getattr(field, "remote_field", None), "on_delete", None) + extra["on_delete"] = getattr(on_delete, "__name__", None) if on_delete else None + extra["related_name"] = getattr(field, "related_name", None) + if remote is not None: + target = f"{remote._meta.app_label}.{remote.__name__}" + extra["to"] = target + graph.edges.append( + Edge( + src=node_id(NodeType.FIELD, f"{qname}.{fname}"), + dst=node_id(NodeType.MODEL, target), + type=EdgeType.RELATES_TO, + extra={"on_delete": extra["on_delete"], "booted": True}, + ) + ) + graph.nodes.append( + Node( + id=node_id(NodeType.FIELD, f"{qname}.{fname}"), + type=NodeType.FIELD, + name=fname, + qualified_name=f"{qname}.{fname}", + context=context, + extra=extra, + ) + ) + graph.edges.append( + Edge( + src=node_id(NodeType.MODEL, qname), + dst=node_id(NodeType.FIELD, f"{qname}.{fname}"), + type=EdgeType.HAS_FIELD, + ) + ) + graph.residuals.append(f"django.setup() overlay applied via {settings_mod}") + except Exception as exc: # noqa: BLE001 + graph.residuals.append(f"django.setup() skipped: {exc}") + finally: + if prev is None: + os.environ.pop("DJANGO_SETTINGS_MODULE", None) + else: + os.environ["DJANGO_SETTINGS_MODULE"] = prev + for p in added: + if p in sys.path: + sys.path.remove(p) + return graph + + +def _discover_settings_module(repo_root: Path, django_root: str = "backend") -> str | None: + env = os.environ.get("DJANGO_SETTINGS_MODULE") + if env: + return env + django_path = (repo_root / django_root).resolve() + candidates: list[Path] = [] + for settings in repo_root.rglob("settings.py"): + rel = settings.relative_to(repo_root) + if any(part.startswith(".") for part in rel.parts): + continue + if "site-packages" in rel.parts: + continue + candidates.append(settings) + if not candidates: + return None + for settings in candidates: + try: + rel = settings.resolve().relative_to(django_path).with_suffix("") + return ".".join(rel.parts) + except ValueError: + continue + rel = candidates[0].relative_to(repo_root).with_suffix("") + return ".".join(rel.parts) diff --git a/src/loadpath/extractors/react.py b/src/loadpath/extractors/react.py new file mode 100644 index 0000000..b8322a0 --- /dev/null +++ b/src/loadpath/extractors/react.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from loadpath.config import LoadpathConfig +from loadpath.types import Edge, EdgeType, ExtractedGraph, Node, NodeType, node_id + +IMPORT_RE = re.compile( + r"""^\s*import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]""" + r"""|^\s*import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]+)['"]""" + r"""|^\s*import\s+['"]([^'"]+)['"]""", + re.M, +) +COMPONENT_RE = re.compile( + r"""(?:export\s+)?(?:default\s+)?(?:function|const|let)\s+([A-Z][A-Za-z0-9_]*)\s*(?:=|\()""" +) +HOOK_RE = re.compile( + r"""(?:export\s+)?(?:function|const|let)\s+(use[A-Z][A-Za-z0-9_]*)\s*(?:=|\()""" +) +USE_QUERY_RE = re.compile( + r"""use(?:Query|Mutation|InfiniteQuery|SuspenseQuery)\s*\(\s*\{(?P.*?)\}\s*\)""", + re.S, +) +QUERY_KEY_RE = re.compile(r"""queryKey\s*:\s*(\[[^\]]*\])""") +FETCH_RE = re.compile( + r"""(?:fetch|axios\.(?:get|post|put|patch|delete)|api\.(?:get|post|put|patch|delete))\s*\(\s*(['"`])(?P.*?)\1""", + re.S, +) +TEMPLATE_FETCH_RE = re.compile( + r"""(?:fetch|axios\.(?:get|post|put|patch|delete)|api\.(?:get|post|put|patch|delete))\s*\(\s*(`(?P[^`]+)`|'([^']+)'|"([^"]+)")""" +) +ROUTE_JSX_RE = re.compile( + r"""]*)>""", + re.S, +) +ROUTE_ATTR_PATH = re.compile(r"""path\s*=\s*{?\s*['"]([^'"]+)['"]""") +ROUTE_ATTR_ELEMENT = re.compile(r"""element\s*=\s*\{\s*<([A-Z][A-Za-z0-9_]*)""") +ZOD_RE = re.compile( + r"""(?:export\s+)?const\s+(\w+)\s*=\s*z\.object\(\s*\{(.*?)\}\s*\)""", + re.S, +) +ZOD_FIELD_RE = re.compile(r"""^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:""", re.M) +CONTEXT_RE = re.compile( + r"""(?:createContext|React\.createContext)\s*\(""" +) +PROVIDER_RE = re.compile( + r"""(?:export\s+)?(?:function|const)\s+(\w*Provider)\b""" +) +TEST_IMPORT_RE = re.compile( + r"""from\s+['"]([^'"]+)['"]""" +) +RTL_RENDER_RE = re.compile(r"""\brender\s*\(\s*<([A-Z][A-Za-z0-9_]*)""") +ROUTER_OBJ_RE = re.compile( + r"""(?:createBrowserRouter|createHashRouter|createRoutesFromElements)\s*\(""", +) +PATH_OBJ_RE = re.compile( + r"""path\s*:\s*['"]([^'"]+)['"][^}]*?(?:element|Component)\s*:\s* str | None: + m = FEATURE_FOLDER_RE.search(rel.replace("\\", "/")) + return m.group(1) if m else None + + +def normalize_url_template(url: str) -> str: + url = url.strip() + url = re.sub(r"""\$\{[^}]+\}""", "{id}", url) + url = re.sub(r""":[A-Za-z_][A-Za-z0-9_]*""", "{id}", url) + url = re.sub(r"""<[^>]+>""", "{id}", url) + if not url.startswith("/"): + if url.startswith("http"): + # strip origin + url = re.sub(r"""https?://[^/]+""", "", url) + else: + url = "/" + url + url = re.sub(r"""/+""", "/", url) + return url.rstrip("/") or "/" + + +def extract_react_file(rel_path: str, source: str, config: LoadpathConfig) -> ExtractedGraph: + rel = rel_path.replace("\\", "/") + graph = ExtractedGraph() + feature = _feature_from_path(rel) + context = config.context_for_react_path(rel) + is_shared = config.is_shared_react(rel) + is_test = bool(re.search(r"""(\.test|\.spec)\.(t|j)sx?$""", rel) or "/__tests__/" in rel) + stem = Path(rel).stem + + def add(ntype: NodeType, name: str, qname: str, line: int = 1, extra: dict | None = None) -> Node: + n = Node( + id=node_id(ntype, qname), + type=ntype, + name=name, + qualified_name=qname, + file_path=rel, + start_line=line, + context=context, + extra=extra or {}, + ) + graph.nodes.append(n) + return n + + def edge(src: str, dst: str, etype: EdgeType, confidence: float = 1.0, extra: dict | None = None) -> None: + graph.edges.append(Edge(src=src, dst=dst, type=etype, confidence=confidence, extra=extra or {})) + + if feature: + feat = add(NodeType.FEATURE_MODULE, feature, f"features.{feature}", extra={"shared": is_shared}) + + imports: list[tuple[str, str]] = [] + import_feature: dict[str, str] = {} + for m in IMPORT_RE.finditer(source): + if m.group(1) and m.group(2): + names = [n.strip().split(" as ")[-1] for n in m.group(1).split(",") if n.strip()] + for name in names: + imports.append((name, m.group(2))) + feat = _feature_from_path(m.group(2)) + if feat: + import_feature[name] = feat + elif m.group(3) and m.group(4): + imports.append((m.group(3), m.group(4))) + feat = _feature_from_path(m.group(4)) + if feat: + import_feature[m.group(3)] = feat + + for local, source_mod in imports: + if feature and ("/shared/" in source_mod or source_mod.startswith("shared") or "/features/" in source_mod): + other = _feature_from_path(source_mod) + if other and other != feature: + edge( + node_id(NodeType.FEATURE_MODULE, f"features.{feature}"), + node_id(NodeType.FEATURE_MODULE, f"features.{other}"), + EdgeType.IMPORTS, + extra={"from": rel, "import": source_mod}, + ) + edge_src = node_id(NodeType.FEATURE_MODULE, f"features.{feature}") if feature else node_id( + NodeType.COMPONENT, f"{rel}:{stem}" + ) + graph.edges.append( + Edge( + src=edge_src, + dst=node_id(NodeType.COMPONENT, source_mod), + type=EdgeType.IMPORTS, + confidence=0.6, + extra={"local": local, "placeholder": True}, + ) + ) + + components: list[Node] = [] + for m in COMPONENT_RE.finditer(source): + name = m.group(1) + line = source[: m.start()].count("\n") + 1 + is_page = name.endswith("Page") or "pages/" in rel or name.endswith("Screen") + ntype = NodeType.PAGE if is_page else NodeType.COMPONENT + n = add(ntype, name, f"{feature or 'app'}.{name}", line, {"feature": feature}) + components.append(n) + if feature: + edge(n.id, node_id(NodeType.FEATURE_MODULE, f"features.{feature}"), EdgeType.BELONGS_TO) + + hooks: list[Node] = [] + for m in HOOK_RE.finditer(source): + name = m.group(1) + line = source[: m.start()].count("\n") + 1 + n = add(NodeType.HOOK, name, f"{feature or 'app'}.{name}", line, {"feature": feature}) + hooks.append(n) + if feature: + edge(n.id, node_id(NodeType.FEATURE_MODULE, f"features.{feature}"), EdgeType.BELONGS_TO) + + for m in re.finditer(r"""\b(use[A-Z][A-Za-z0-9_]*)\s*\(""", source): + hook_name = m.group(1) + if hook_name in {"useQuery", "useMutation", "useEffect", "useState", "useMemo", "useCallback", "useRef"}: + continue + for owner in components: + edge( + owner.id, + node_id(NodeType.HOOK, f"{feature or 'app'}.{hook_name}"), + EdgeType.CALLS, + confidence=0.9, + ) + + for m in USE_QUERY_RE.finditer(source): + body = m.group("body") + line = source[: m.start()].count("\n") + 1 + km = QUERY_KEY_RE.search(body) + key_raw = km.group(1) if km else None + if key_raw: + key_name = re.sub(r"""\s+""", "", key_raw) + qn = add( + NodeType.QUERY_KEY, + key_name, + f"{feature or 'app'}.queryKey.{key_name}", + line, + {"raw": key_raw, "feature": feature}, + ) + for h in hooks or components: + edge(h.id, qn.id, EdgeType.USES_QUERY_KEY) + + for m in TEMPLATE_FETCH_RE.finditer(source): + url = m.group("turl") or m.group(3) or m.group(4) + if not url: + continue + if "/api/" not in url and not url.startswith("/"): + continue + line = source[: m.start()].count("\n") + 1 + norm = normalize_url_template(url) + client = add( + NodeType.API_CLIENT, + norm, + f"client:{norm}", + line, + {"raw": url, "inferred": True, "feature": feature, "file": rel}, + ) + for owner in hooks or components: + edge(owner.id, client.id, EdgeType.CALLS) + if feature: + edge(node_id(NodeType.FEATURE_MODULE, f"features.{feature}"), client.id, EdgeType.CALLS) + + # also catch fetch(`/api/...`) missed by grouping + for m in re.finditer(r"""[`'"](/api/[^`'"]+)[`'"]""", source): + url = m.group(1) + line = source[: m.start()].count("\n") + 1 + norm = normalize_url_template(url) + if any(n.qualified_name == f"client:{norm}" for n in graph.nodes): + continue + add( + NodeType.API_CLIENT, + norm, + f"client:{norm}", + line, + {"raw": url, "inferred": True, "feature": feature, "file": rel}, + ) + + for m in ROUTE_JSX_RE.finditer(source): + attrs = m.group(1) + path_m = ROUTE_ATTR_PATH.search(attrs) + el_m = ROUTE_ATTR_ELEMENT.search(attrs) + if not path_m: + continue + rpath = path_m.group(1) + line = source[: m.start()].count("\n") + 1 + page_name = el_m.group(1) if el_m else rpath + rn = add(NodeType.REACT_ROUTE, rpath, f"react.route:{rpath}", line, {"element": page_name}) + if el_m: + feat = import_feature.get(page_name) or feature or "app" + page = add(NodeType.PAGE, page_name, f"{feat}.{page_name}", line, {"feature": feat, "from_route": True}) + edge(rn.id, page.id, EdgeType.PUBLISHES_ROUTE) + edge(rn.id, node_id(NodeType.COMPONENT, f"{feat}.{page_name}"), EdgeType.RENDERS) + + for m in PATH_OBJ_RE.finditer(source): + rpath, page_name = m.group(1), m.group(2) + line = source[: m.start()].count("\n") + 1 + rn = add(NodeType.REACT_ROUTE, rpath, f"react.route:{rpath}", line, {"element": page_name}) + edge(rn.id, node_id(NodeType.PAGE, f"{feature or 'app'}.{page_name}"), EdgeType.PUBLISHES_ROUTE) + + for m in ZOD_RE.finditer(source): + name, body = m.group(1), m.group(2) + line = source[: m.start()].count("\n") + 1 + fields = ZOD_FIELD_RE.findall(body) + schema = add( + NodeType.FORM_SCHEMA, + name, + f"{feature or 'app'}.{name}", + line, + {"fields": fields, "kind": "zod", "feature": feature}, + ) + for owner in components: + edge(owner.id, schema.id, EdgeType.CALLS, confidence=0.7) + + if CONTEXT_RE.search(source): + for m in PROVIDER_RE.finditer(source): + name = m.group(1) + line = source[: m.start()].count("\n") + 1 + add(NodeType.CONTEXT_PROVIDER, name, f"{feature or 'app'}.{name}", line) + + if is_test: + line = 1 + tn = add(NodeType.REACT_TEST, stem, f"test.{rel}", line, {"file": rel}) + for m in RTL_RENDER_RE.finditer(source): + name = m.group(1) + edge(node_id(NodeType.PAGE, f"{feature or 'app'}.{name}"), tn.id, EdgeType.TESTED_BY) + edge(node_id(NodeType.COMPONENT, f"{feature or 'app'}.{name}"), tn.id, EdgeType.TESTED_BY) + for m in TEST_IMPORT_RE.finditer(source): + spec = m.group(1) + if spec.startswith(".") or "features/" in spec: + base = Path(spec).stem + if base[0:1].isupper() or base.startswith("use"): + ntype = NodeType.HOOK if base.startswith("use") else NodeType.COMPONENT + edge(node_id(ntype, f"{feature or 'app'}.{base}"), tn.id, EdgeType.TESTED_BY, confidence=0.75) + + # composition: JSX usage of other components + defined = {n.name for n in components} + for other in re.findall(r"""<([A-Z][A-Za-z0-9_]*)\b""", source): + if other in {"Route", "Routes", "BrowserRouter", "QueryClientProvider"}: + continue + if other in defined: + continue + for owner in components: + edge( + owner.id, + node_id(NodeType.COMPONENT, f"{feature or 'app'}.{other}"), + EdgeType.RENDERS, + confidence=0.8, + ) + edge( + owner.id, + node_id(NodeType.PAGE, f"{feature or 'app'}.{other}"), + EdgeType.RENDERS, + confidence=0.5, + ) + + return graph diff --git a/src/loadpath/graph/__init__.py b/src/loadpath/graph/__init__.py new file mode 100644 index 0000000..24476dd --- /dev/null +++ b/src/loadpath/graph/__init__.py @@ -0,0 +1,3 @@ +from loadpath.graph.store import GraphStore + +__all__ = ["GraphStore"] diff --git a/src/loadpath/graph/store.py b/src/loadpath/graph/store.py new file mode 100644 index 0000000..2252872 --- /dev/null +++ b/src/loadpath/graph/store.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any, Iterable, Iterator + +from loadpath.types import Edge, ExtractedGraph, Node, NodeType + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + hash TEXT NOT NULL, + language TEXT +); + +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + qualified_name TEXT NOT NULL, + file_path TEXT, + start_line INTEGER, + end_line INTEGER, + context TEXT, + extra TEXT NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS edges ( + id TEXT PRIMARY KEY, + src TEXT NOT NULL, + dst TEXT NOT NULL, + type TEXT NOT NULL, + weight TEXT NOT NULL, + confidence REAL NOT NULL, + extra TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path); +CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type); +CREATE INDEX IF NOT EXISTS idx_nodes_context ON nodes(context); +CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src); +CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst); +CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type); + +CREATE TABLE IF NOT EXISTS reviews ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + repo_root TEXT NOT NULL, + base_ref TEXT, + head_ref TEXT, + payload TEXT NOT NULL +); +""" + + +class GraphStore: + def __init__(self, db_path: Path) -> None: + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(str(self.db_path)) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA foreign_keys = ON") + self.conn.executescript(SCHEMA) + self.conn.commit() + + def close(self) -> None: + self.conn.close() + + def __enter__(self) -> GraphStore: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def set_meta(self, key: str, value: str) -> None: + self.conn.execute( + "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value), + ) + self.conn.commit() + + def get_meta(self, key: str) -> str | None: + row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() + return row["value"] if row else None + + def file_hash(self, path: str) -> str | None: + row = self.conn.execute("SELECT hash FROM files WHERE path=?", (path,)).fetchone() + return row["hash"] if row else None + + def upsert_file(self, path: str, digest: str, language: str | None) -> None: + self.conn.execute( + "INSERT INTO files(path, hash, language) VALUES(?,?,?) " + "ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, language=excluded.language", + (path, digest, language), + ) + + def delete_file_nodes(self, path: str, *, drop_incoming: bool = True) -> None: + ids = [ + r["id"] + for r in self.conn.execute("SELECT id FROM nodes WHERE file_path=?", (path,)).fetchall() + ] + if not ids: + self.conn.execute("DELETE FROM files WHERE path=?", (path,)) + return + placeholders = ",".join("?" * len(ids)) + if drop_incoming: + self.conn.execute( + f"DELETE FROM edges WHERE src IN ({placeholders}) OR dst IN ({placeholders})", + ids + ids, + ) + else: + # Reindex: keep edges from other files (e.g. view ENQUEUES → task). + self.conn.execute(f"DELETE FROM edges WHERE src IN ({placeholders})", ids) + self.conn.execute("DELETE FROM nodes WHERE file_path=?", (path,)) + self.conn.execute("DELETE FROM files WHERE path=?", (path,)) + + def prune_dangling_edges(self) -> None: + self.conn.execute( + "DELETE FROM edges WHERE src NOT IN (SELECT id FROM nodes) OR dst NOT IN (SELECT id FROM nodes)" + ) + + def upsert_graph(self, graph: ExtractedGraph) -> None: + for node in graph.nodes: + self.upsert_node(node) + for edge in graph.edges: + self.upsert_edge(edge) + self.conn.commit() + + def upsert_node(self, node: Node) -> None: + existing = self.get_node(node.id) + extra = dict(node.extra or {}) + file_path = node.file_path + start_line = node.start_line + context = node.context + if existing: + merged = dict(existing.get("extra") or {}) + merged.update(extra) + extra = merged + new_is_ref = bool(node.extra.get("referenced")) + old_is_ref = bool((existing.get("extra") or {}).get("referenced")) + # A definition (tasks.py / @actor) wins over a call-site placeholder. + if new_is_ref and not old_is_ref: + extra["referenced"] = False + for k, v in (existing.get("extra") or {}).items(): + if k not in node.extra or node.extra.get(k) is None: + extra[k] = v + file_path = existing.get("file_path") or file_path + start_line = existing.get("start_line") if existing.get("start_line") is not None else start_line + context = existing.get("context") or context + elif not new_is_ref and old_is_ref: + extra["referenced"] = False + self.conn.execute( + """ + INSERT INTO nodes(id, type, name, qualified_name, file_path, start_line, end_line, context, extra) + VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + type=excluded.type, + name=excluded.name, + qualified_name=excluded.qualified_name, + file_path=excluded.file_path, + start_line=excluded.start_line, + end_line=excluded.end_line, + context=excluded.context, + extra=excluded.extra + """, + ( + node.id, + node.type.value, + node.name, + node.qualified_name, + file_path, + start_line, + node.end_line, + context, + json.dumps(extra), + ), + ) + + def upsert_edge(self, edge: Edge) -> None: + row = edge.to_row() + self.conn.execute( + """ + INSERT INTO edges(id, src, dst, type, weight, confidence, extra) + VALUES(?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + weight=excluded.weight, + confidence=excluded.confidence, + extra=excluded.extra + """, + ( + row["id"], + row["src"], + row["dst"], + row["type"], + row["weight"], + row["confidence"], + json.dumps(row["extra"]), + ), + ) + + def get_node(self, node_id: str) -> dict[str, Any] | None: + row = self.conn.execute("SELECT * FROM nodes WHERE id=?", (node_id,)).fetchone() + return self._node_from_row(row) if row else None + + def nodes(self, types: Iterable[NodeType] | None = None) -> list[dict[str, Any]]: + if types: + values = [t.value for t in types] + placeholders = ",".join("?" * len(values)) + rows = self.conn.execute( + f"SELECT * FROM nodes WHERE type IN ({placeholders})", values + ).fetchall() + else: + rows = self.conn.execute("SELECT * FROM nodes").fetchall() + return [self._node_from_row(r) for r in rows] + + def edges(self) -> list[dict[str, Any]]: + rows = self.conn.execute("SELECT * FROM edges").fetchall() + return [self._edge_from_row(r) for r in rows] + + def neighbors(self, node_id: str, direction: str = "both") -> list[dict[str, Any]]: + clauses = [] + args: list[str] = [] + if direction in ("out", "both"): + clauses.append("src=?") + args.append(node_id) + if direction in ("in", "both"): + clauses.append("dst=?") + args.append(node_id) + rows = self.conn.execute( + f"SELECT * FROM edges WHERE {' OR '.join(clauses)}", args + ).fetchall() + return [self._edge_from_row(r) for r in rows] + + def nodes_in_files(self, paths: Iterable[str]) -> list[dict[str, Any]]: + path_list = list(paths) + if not path_list: + return [] + placeholders = ",".join("?" * len(path_list)) + rows = self.conn.execute( + f"SELECT * FROM nodes WHERE file_path IN ({placeholders})", path_list + ).fetchall() + return [self._node_from_row(r) for r in rows] + + def subgraph(self, seed_ids: Iterable[str], hops: int = 6) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + frontier = set(seed_ids) + seen_nodes = set(frontier) + seen_edges: dict[str, dict[str, Any]] = {} + for _ in range(hops): + if not frontier: + break + nxt: set[str] = set() + for nid in frontier: + for edge in self.neighbors(nid, "both"): + seen_edges[edge["id"]] = edge + other = edge["dst"] if edge["src"] == nid else edge["src"] + if other not in seen_nodes: + seen_nodes.add(other) + nxt.add(other) + frontier = nxt + nodes = [self.get_node(i) for i in seen_nodes] + return [n for n in nodes if n], list(seen_edges.values()) + + def iter_nodes(self) -> Iterator[dict[str, Any]]: + for row in self.conn.execute("SELECT * FROM nodes"): + yield self._node_from_row(row) + + def save_review(self, review_id: str, created_at: str, repo_root: str, base_ref: str, head_ref: str, payload: dict[str, Any]) -> None: + self.conn.execute( + "INSERT INTO reviews(id, created_at, repo_root, base_ref, head_ref, payload) VALUES(?,?,?,?,?,?) " + "ON CONFLICT(id) DO UPDATE SET payload=excluded.payload", + (review_id, created_at, repo_root, base_ref, head_ref, json.dumps(payload)), + ) + self.conn.commit() + + def get_review(self, review_id: str) -> dict[str, Any] | None: + row = self.conn.execute("SELECT * FROM reviews WHERE id=?", (review_id,)).fetchone() + if not row: + return None + return { + "id": row["id"], + "created_at": row["created_at"], + "repo_root": row["repo_root"], + "base_ref": row["base_ref"], + "head_ref": row["head_ref"], + "payload": json.loads(row["payload"]), + } + + def list_reviews(self) -> list[dict[str, Any]]: + rows = self.conn.execute( + "SELECT id, created_at, repo_root, base_ref, head_ref FROM reviews ORDER BY created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + + def counts(self) -> dict[str, int]: + n = self.conn.execute("SELECT COUNT(*) AS c FROM nodes").fetchone()["c"] + e = self.conn.execute("SELECT COUNT(*) AS c FROM edges").fetchone()["c"] + return {"nodes": n, "edges": e} + + def type_counts(self) -> dict[str, int]: + rows = self.conn.execute("SELECT type, COUNT(*) AS c FROM nodes GROUP BY type").fetchall() + return {r["type"]: r["c"] for r in rows} + + def file_count(self) -> int: + return self.conn.execute("SELECT COUNT(*) AS c FROM files").fetchone()["c"] + + def indexed_paths(self) -> list[str]: + return [r["path"] for r in self.conn.execute("SELECT path FROM files").fetchall()] + + @staticmethod + def _node_from_row(row: sqlite3.Row) -> dict[str, Any]: + extra = json.loads(row["extra"] or "{}") + return { + "id": row["id"], + "type": row["type"], + "name": row["name"], + "qualified_name": row["qualified_name"], + "file_path": row["file_path"], + "start_line": row["start_line"], + "end_line": row["end_line"], + "context": row["context"], + "extra": extra, + } + + @staticmethod + def _edge_from_row(row: sqlite3.Row) -> dict[str, Any]: + extra = json.loads(row["extra"] or "{}") + return { + "id": row["id"], + "src": row["src"], + "dst": row["dst"], + "type": row["type"], + "weight": row["weight"], + "confidence": row["confidence"], + "extra": extra, + } diff --git a/src/loadpath/index.py b/src/loadpath/index.py new file mode 100644 index 0000000..47ca038 --- /dev/null +++ b/src/loadpath/index.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from pathlib import Path + +from loadpath.config import LoadpathConfig, load_config +from loadpath.extractors.django import extract_django_file +from loadpath.extractors.react import extract_react_file +from loadpath.extractors.django_boot import try_boot_models +from loadpath.graph.store import GraphStore +from loadpath.stitch.openapi import stitch +from loadpath.types import GENERATED_PATH_MARKERS, ExtractedGraph, Node, NodeType, node_id + +PY_SKIP = {"migrations"} # still extract migrations, just not skip +INDEX_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx"} + + +def default_db_path(repo_root: Path) -> Path: + return repo_root / ".loadpath" / "graph.sqlite3" + + +def iter_source_files(repo_root: Path, config: LoadpathConfig) -> list[Path]: + files: list[Path] = [] + skip_dirs = { + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".loadpath", + "dist", + "build", + ".mypy_cache", + ".pytest_cache", + } + for path in repo_root.rglob("*"): + if not path.is_file() or path.suffix not in INDEX_EXTENSIONS: + continue + rel = path.relative_to(repo_root).as_posix() + if any(part in skip_dirs for part in path.parts): + continue + if any(m in rel for m in GENERATED_PATH_MARKERS if m.endswith("/") and m not in {"generated/"}): + # still index generated clients + pass + if path.name in {"package-lock.json"}: + continue + files.append(path) + return files + + +def file_hash(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def language_for(path: Path) -> str: + if path.suffix == ".py": + return "python" + if path.suffix in {".ts", ".tsx"}: + return "typescript" + return "javascript" + + +def index_repo( + repo_root: Path, + db_path: Path | None = None, + config: LoadpathConfig | None = None, + incremental: bool = True, +) -> GraphStore: + repo_root = repo_root.resolve() + config = config or load_config(repo_root) + db_path = db_path or default_db_path(repo_root) + store = GraphStore(db_path) + store.set_meta("repo_root", str(repo_root)) + + residuals: list[str] = [] + skipped: set[str] = set() + files = iter_source_files(repo_root, config) + present = {path.relative_to(repo_root).as_posix() for path in files} + for stale in store.indexed_paths(): + if stale not in present: + store.delete_file_nodes(stale) + for path in files: + rel = path.relative_to(repo_root).as_posix() + digest = file_hash(path) + if incremental and store.file_hash(rel) == digest: + skipped.add(rel) + continue + store.delete_file_nodes(rel, drop_incoming=False) + source = path.read_text(encoding="utf-8", errors="replace") + if path.suffix == ".py": + graph = extract_django_file(rel, source, config) + else: + graph = extract_react_file(rel, source, config) + _assign_contexts(graph, config, rel) + store.upsert_file(rel, digest, language_for(path)) + store.upsert_graph(graph) + residuals.extend(graph.residuals) + + boot = ExtractedGraph() + if config.boot_django: + boot = try_boot_models(repo_root, config) + store.upsert_graph(boot) + residuals.extend(boot.residuals) + + _ensure_context_nodes(store, config) + stitch_residuals = stitch(store, config, repo_root) + store.prune_dangling_edges() + if incremental: + old = [line for line in (store.get_meta("residuals") or "").splitlines() if line] + changed = present - skipped + preserved = [ + line + for line in old + if any(rel in line for rel in skipped) and not any(rel in line for rel in changed) + ] + residuals = preserved + residuals + stitch_residuals + seen: set[str] = set() + deduped: list[str] = [] + for line in residuals: + if line not in seen: + seen.add(line) + deduped.append(line) + residuals = deduped + else: + residuals.extend(stitch_residuals) + store.set_meta("residuals", "\n".join(residuals)) + store.set_meta("indexed_at", datetime.now(timezone.utc).isoformat()) + store.set_meta("incremental", "1" if incremental else "0") + store.conn.commit() + return store + + +def _assign_contexts(graph: ExtractedGraph, config: LoadpathConfig, rel: str) -> None: + for node in graph.nodes: + if node.context: + continue + if node.type.value.startswith("django.") or node.type.value.startswith("react."): + if rel.endswith(".py"): + app = (node.extra or {}).get("app") + if app: + node.context = config.context_for_django_app(app) + else: + node.context = config.context_for_react_path(rel) + + +def _ensure_context_nodes(store: GraphStore, config: LoadpathConfig) -> None: + for name, ctx in config.contexts.items(): + store.upsert_node( + Node( + id=node_id(NodeType.BOUNDED_CONTEXT, name), + type=NodeType.BOUNDED_CONTEXT, + name=name, + qualified_name=name, + extra={"django_apps": ctx.django_apps, "react": ctx.react, "public_api": ctx.public_api}, + ) + ) + for app in ctx.django_apps: + store.upsert_node( + Node( + id=node_id(NodeType.APP, app), + type=NodeType.APP, + name=app, + qualified_name=app, + context=name, + ) + ) + store.conn.execute( + """ + INSERT OR IGNORE INTO edges(id, src, dst, type, weight, confidence, extra) + VALUES(?,?,?,?,?,?,?) + """, + ( + f"{node_id(NodeType.APP, app)}|belongs_to|{node_id(NodeType.BOUNDED_CONTEXT, name)}", + node_id(NodeType.APP, app), + node_id(NodeType.BOUNDED_CONTEXT, name), + "belongs_to", + "cheap", + 1.0, + "{}", + ), + ) diff --git a/src/loadpath/providers/__init__.py b/src/loadpath/providers/__init__.py new file mode 100644 index 0000000..9f62a5e --- /dev/null +++ b/src/loadpath/providers/__init__.py @@ -0,0 +1,3 @@ +from loadpath.providers.scm import BitbucketProvider, GitHubProvider, PullRequest, provider_for + +__all__ = ["BitbucketProvider", "GitHubProvider", "PullRequest", "provider_for"] diff --git a/src/loadpath/providers/scm.py b/src/loadpath/providers/scm.py new file mode 100644 index 0000000..6a91210 --- /dev/null +++ b/src/loadpath/providers/scm.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx + +REPO_SLUG = re.compile(r"^[\w.-]+/[\w.-]+$") + + +def require_repo_slug(repo: str) -> str: + slug = repo.strip().strip("/") + if not REPO_SLUG.match(slug): + raise ValueError("repo must be owner/name") + return slug + + +@dataclass +class PullRequest: + provider: str + id: str + number: int + title: str + url: str + author: str + source_branch: str + target_branch: str + repo: str + state: str + updated_at: str + draft: bool = False + + def to_dict(self) -> dict[str, Any]: + return self.__dict__.copy() + + +class SCMProvider(Protocol): + name: str + + def list_pull_requests(self, repo: str, state: str = "open") -> list[PullRequest]: ... + + def get_pull_request(self, repo: str, number: int) -> PullRequest: ... + + def get_diff(self, repo: str, number: int) -> str: ... + + +class GitHubProvider: + name = "github" + + def __init__(self, token: str, client: httpx.Client | None = None) -> None: + self.token = token + self.client = client or httpx.Client(timeout=30.0) + self.base = "https://api.github.com" + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def list_pull_requests(self, repo: str, state: str = "open") -> list[PullRequest]: + repo = require_repo_slug(repo) + r = self.client.get( + f"{self.base}/repos/{repo}/pulls", + params={"state": state, "per_page": 50, "sort": "updated"}, + headers=self._headers(), + ) + r.raise_for_status() + out = [] + for item in r.json(): + out.append( + PullRequest( + provider="github", + id=str(item["id"]), + number=item["number"], + title=item["title"], + url=item["html_url"], + author=(item.get("user") or {}).get("login") or "", + source_branch=(item.get("head") or {}).get("ref") or "", + target_branch=(item.get("base") or {}).get("ref") or "", + repo=repo, + state=item.get("state") or "open", + updated_at=item.get("updated_at") or "", + draft=bool(item.get("draft")), + ) + ) + return out + + def get_pull_request(self, repo: str, number: int) -> PullRequest: + repo = require_repo_slug(repo) + r = self.client.get(f"{self.base}/repos/{repo}/pulls/{number}", headers=self._headers()) + r.raise_for_status() + item = r.json() + return PullRequest( + provider="github", + id=str(item["id"]), + number=item["number"], + title=item["title"], + url=item["html_url"], + author=(item.get("user") or {}).get("login") or "", + source_branch=(item.get("head") or {}).get("ref") or "", + target_branch=(item.get("base") or {}).get("ref") or "", + repo=repo, + state=item.get("state") or "open", + updated_at=item.get("updated_at") or "", + draft=bool(item.get("draft")), + ) + + def get_diff(self, repo: str, number: int) -> str: + repo = require_repo_slug(repo) + r = self.client.get( + f"{self.base}/repos/{repo}/pulls/{number}", + headers={**self._headers(), "Accept": "application/vnd.github.diff"}, + ) + r.raise_for_status() + return r.text + + +class BitbucketProvider: + name = "bitbucket" + + def __init__( + self, + token: str, + username: str = "", + client: httpx.Client | None = None, + ) -> None: + self.token = token + self.username = username + self.client = client or httpx.Client(timeout=30.0) + self.base = "https://api.bitbucket.org/2.0" + + def _auth(self) -> tuple[str, str] | None: + if self.username: + return (self.username, self.token) + return None + + def _headers(self) -> dict[str, str]: + if self.username: + return {} + return {"Authorization": f"Bearer {self.token}"} + + def list_pull_requests(self, repo: str, state: str = "open") -> list[PullRequest]: + repo = require_repo_slug(repo) + bb_state = "OPEN" if state == "open" else state.upper() + r = self.client.get( + f"{self.base}/repositories/{repo}/pullrequests", + params={"state": bb_state, "pagelen": 50}, + headers=self._headers(), + auth=self._auth(), + ) + r.raise_for_status() + out = [] + for item in r.json().get("values") or []: + src = ((item.get("source") or {}).get("branch") or {}).get("name") or "" + dst = ((item.get("destination") or {}).get("branch") or {}).get("name") or "" + links = item.get("links") or {} + html = (links.get("html") or {}).get("href") or "" + author = ((item.get("author") or {}).get("display_name")) or "" + out.append( + PullRequest( + provider="bitbucket", + id=str(item.get("id")), + number=int(item.get("id")), + title=item.get("title") or "", + url=html, + author=author, + source_branch=src, + target_branch=dst, + repo=repo, + state=(item.get("state") or "").lower(), + updated_at=item.get("updated_on") or "", + draft=False, + ) + ) + return out + + def get_pull_request(self, repo: str, number: int) -> PullRequest: + repo = require_repo_slug(repo) + r = self.client.get( + f"{self.base}/repositories/{repo}/pullrequests/{number}", + headers=self._headers(), + auth=self._auth(), + ) + r.raise_for_status() + item = r.json() + src = ((item.get("source") or {}).get("branch") or {}).get("name") or "" + dst = ((item.get("destination") or {}).get("branch") or {}).get("name") or "" + html = ((item.get("links") or {}).get("html") or {}).get("href") or "" + return PullRequest( + provider="bitbucket", + id=str(item.get("id")), + number=int(item.get("id")), + title=item.get("title") or "", + url=html, + author=((item.get("author") or {}).get("display_name")) or "", + source_branch=src, + target_branch=dst, + repo=repo, + state=(item.get("state") or "").lower(), + updated_at=item.get("updated_on") or "", + ) + + def get_diff(self, repo: str, number: int) -> str: + repo = require_repo_slug(repo) + r = self.client.get( + f"{self.base}/repositories/{repo}/pullrequests/{number}/diff", + headers=self._headers(), + auth=self._auth(), + ) + r.raise_for_status() + return r.text + + +def provider_for(name: str, token: str, username: str = "", client: httpx.Client | None = None) -> SCMProvider: + if name == "github": + return GitHubProvider(token, client=client) + if name == "bitbucket": + return BitbucketProvider(token, username=username, client=client) + raise ValueError(f"Unknown SCM provider: {name}") diff --git a/src/loadpath/report/graph.html b/src/loadpath/report/graph.html new file mode 100644 index 0000000..27fc3a1 --- /dev/null +++ b/src/loadpath/report/graph.html @@ -0,0 +1,109 @@ + + + + + Loadpath — {{ review.title }} + + + + + +
+ + + diff --git a/src/loadpath/review/__init__.py b/src/loadpath/review/__init__.py new file mode 100644 index 0000000..7532f76 --- /dev/null +++ b/src/loadpath/review/__init__.py @@ -0,0 +1,4 @@ +from loadpath.review.engine import run_review +from loadpath.review.render import render_html, render_markdown + +__all__ = ["run_review", "render_html", "render_markdown"] diff --git a/src/loadpath/review/cluster.py b/src/loadpath/review/cluster.py new file mode 100644 index 0000000..32fc8d0 --- /dev/null +++ b/src/loadpath/review/cluster.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from collections import defaultdict + +from loadpath.graph.store import GraphStore +from loadpath.review.diff import DiffSet +from loadpath.types import NodeType, SINK_TYPES + +CLUSTER_SEED_PRIORITY = [ + NodeType.SERIALIZER, + NodeType.SERIALIZER_FIELD, + NodeType.MODEL, + NodeType.FIELD, + NodeType.ROUTE, + NodeType.VIEW, + NodeType.TASK, + NodeType.RECEIVER, + NodeType.PAGE, + NodeType.FORM_SCHEMA, + NodeType.HOOK, + NodeType.COMPONENT, +] + +# Shared hubs that connect unrelated contexts if traversed. +BRIDGE_TYPES = { + NodeType.PERMISSION.value, + NodeType.THROTTLE.value, + NodeType.APP.value, + NodeType.BOUNDED_CONTEXT.value, + NodeType.URL_NAME.value, +} + +FORWARD_TYPES = { + "has_field", + "serializes", + "uses_serializer", + "publishes_route", + "consumed_by_client", + "matches_schema", + "enqueues", + "emits_signal", + "receives", + "calls", + "renders", + "queries_model", + "uses_query_key", + "serves", + "relates_to", + "tested_by", + "has_permission", + "belongs_to", + "destructive_migration", + "changes_permission", + "crosses_context", +} + +BACKWARD_TYPES = { + "uses_serializer", + "publishes_route", + "consumed_by_client", + "has_field", + "serializes", + "calls", + "matches_schema", + "enqueues", + "tested_by", + "queries_model", + "emits_signal", + "receives", + "uses_query_key", + "belongs_to", + "destructive_migration", +} + + +def impact_walk(store: GraphStore, seed_ids: set[str], hops: int = 8) -> tuple[list[dict], list[dict]]: + if not seed_ids: + return [], [] + by_id = {n["id"]: n for n in store.nodes()} + outgoing: dict[str, list[dict]] = defaultdict(list) + incoming: dict[str, list[dict]] = defaultdict(list) + for e in store.edges(): + outgoing[e["src"]].append(e) + incoming[e["dst"]].append(e) + + seen = set(seed_ids) + kept_edges: dict[str, dict] = {} + frontier = set(seed_ids) + + def include(nid: str, expand: bool) -> None: + if nid not in by_id: + return + if nid not in seen: + seen.add(nid) + if expand: + frontier.add(nid) + + seed_contexts = { + (by_id.get(s) or {}).get("context") + for s in seed_ids + if (by_id.get(s) or {}).get("context") + } + + for _ in range(hops): + nxt: set[str] = set() + working = set(frontier) + frontier.clear() + for nid in working: + node = by_id.get(nid) or {} + if node.get("type") in BRIDGE_TYPES: + continue + for e in outgoing.get(nid, []): + if e["type"] not in FORWARD_TYPES: + continue + other = e["dst"] + other_node = by_id.get(other) or {} + expand = other_node.get("type") not in BRIDGE_TYPES + if ( + e["type"] == "relates_to" + and other_node.get("context") + and seed_contexts + and other_node.get("context") not in seed_contexts + ): + continue + if ( + e["type"] == "queries_model" + and other_node.get("context") + and seed_contexts + and other_node.get("context") not in seed_contexts + ): + include(other, False) + kept_edges[e["id"]] = e + continue + include(other, expand) + kept_edges[e["id"]] = e + if expand and other not in working: + nxt.add(other) + for e in incoming.get(nid, []): + if e["type"] not in BACKWARD_TYPES: + continue + if e["type"] == "renders": + continue + if e["type"] == "belongs_to" and node.get("type") != NodeType.FEATURE_MODULE.value: + continue + other = e["src"] + other_node = by_id.get(other) or {} + if other_node.get("type") in BRIDGE_TYPES: + kept_edges[e["id"]] = e + include(other, False) + continue + include(other, True) + kept_edges[e["id"]] = e + if other not in working: + nxt.add(other) + frontier = {i for i in nxt if i not in working} + + nodes = [by_id[i] for i in seen if i in by_id] + return nodes, list(kept_edges.values()) + + +def cluster_diff( + store: GraphStore, + diff: DiffSet, + hops: int = 8, +) -> tuple[list[dict], list[dict], list[dict]]: + """Return (clusters, impact_nodes, impact_edges).""" + changed_paths = [f.path for f in diff.files if not f.skip] + seeds = store.nodes_in_files(changed_paths) + seed_ids = {n["id"] for n in seeds} + nodes, edges = impact_walk(store, seed_ids, hops=hops) if seed_ids else ([], []) + + parent: dict[str, str] = {} + + def find(x: str) -> str: + parent.setdefault(x, x) + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a: str, b: str) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[rb] = ra + + for n in seeds: + find(n["id"]) + for e in edges: + if e["src"] in seed_ids: + union(e["src"], e["dst"]) + if e["dst"] in seed_ids: + union(e["dst"], e["src"]) + + seed_groups: dict[str, list[dict]] = defaultdict(list) + for n in seeds: + seed_groups[find(n["id"])].append(n) + + clusters = [] + for cid, members in seed_groups.items(): + files = sorted({m["file_path"] for m in members if m.get("file_path")}) + title = _cluster_title(members) + related = _related_nodes(members, nodes) + clusters.append( + { + "id": cid, + "title": title, + "files": files, + "seed_ids": [m["id"] for m in members], + "node_ids": [n["id"] for n in related], + "kinds": sorted({m["type"] for m in members}), + "contexts": sorted({m["context"] for m in members if m.get("context")}), + } + ) + clusters.sort(key=lambda c: (-len(c["files"]), c["title"])) + return clusters, nodes, edges + + +def _cluster_title(members: list[dict]) -> str: + by_type = {m["type"]: m for m in members} + for ntype in CLUSTER_SEED_PRIORITY: + if ntype.value in by_type: + m = by_type[ntype.value] + return f"{m['name']} ({ntype.value.split('.')[-1]})" + if members: + ctx = members[0].get("context") or "change" + return f"{ctx} cluster" + return "Unclustered" + + +def _related_nodes(members: list[dict], all_nodes: list[dict]) -> list[dict]: + ids = {m["id"] for m in members} + prefixes = {m["qualified_name"].split(".")[0] for m in members} + related = [] + for n in all_nodes: + if n["id"] in ids: + related.append(n) + continue + q = n.get("qualified_name") or "" + if q.split(".")[0] in prefixes and n.get("type") in {t.value for t in SINK_TYPES}: + related.append(n) + return related diff --git a/src/loadpath/review/confidence.py b/src/loadpath/review/confidence.py new file mode 100644 index 0000000..faa3005 --- /dev/null +++ b/src/loadpath/review/confidence.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from loadpath.architecture.rules import Finding, RuleSeverity +from loadpath.graph.store import GraphStore +from loadpath.types import ( + CONTRACT_TYPES, + ConfidenceLevel, + EdgeType, + NodeType, + SINK_TYPES, +) + +SINK_TYPE_VALUES = {t.value for t in SINK_TYPES} +CONTRACT_TYPE_VALUES = {t.value for t in CONTRACT_TYPES} +TEST_TYPES = {NodeType.TEST.value, NodeType.REACT_TEST.value} + + +def score_confidence( + store: GraphStore, + impact_nodes: list[dict], + impact_edges: list[dict], + findings: list[Finding], + residuals: list[str], +) -> dict: + sinks = [n for n in impact_nodes if n["type"] in SINK_TYPE_VALUES] + if not sinks: + # pages / forms / routes still count + sinks = [ + n + for n in impact_nodes + if n["type"] + in { + NodeType.PAGE.value, + NodeType.ROUTE.value, + NodeType.TASK.value, + NodeType.FORM_SCHEMA.value, + NodeType.OPENAPI_PATH.value, + } + ] + + tested_ids: set[str] = set() + impact_ids = {n["id"] for n in impact_nodes} + all_edges = list(store.edges()) + for e in list(impact_edges) + all_edges: + if e["type"] == EdgeType.TESTED_BY.value: + tested_ids.add(e["src"]) + + # A sink is covered if it, or a producer within two hops (view/serializer/hook/page), is tested. + inbound: dict[str, list[str]] = {} + for e in all_edges: + inbound.setdefault(e["dst"], []).append(e["src"]) + inbound.setdefault(e["src"], []).append(e["dst"]) + + def reachable_tested(nid: str, depth: int = 2) -> bool: + if nid in tested_ids: + return True + seen = {nid} + frontier = [nid] + for _ in range(depth): + nxt = [] + for cur in frontier: + for other in inbound.get(cur, []): + if other in seen: + continue + seen.add(other) + if other in tested_ids: + return True + nxt.append(other) + frontier = nxt + return False + + covered = [s for s in sinks if reachable_tested(s["id"])] + sink_ratio = (len(covered) / len(sinks)) if sinks else 1.0 + + blockers = [f for f in findings if f.severity == RuleSeverity.BLOCKER and not f.waived] + warnings = [f for f in findings if f.severity == RuleSeverity.WARNING and not f.waived] + + inferred = [e for e in impact_edges if e.get("confidence", 1) < 0.8] + unresolved_ratio = (len(inferred) / len(impact_edges)) if impact_edges else 0.0 + + contract_nodes = [n for n in impact_nodes if n["type"] in CONTRACT_TYPE_VALUES] + contract_ok = True + for f in blockers: + if f.rule == "serializers_are_the_only_published_contract": + contract_ok = False + + reasons: list[str] = [] + level = ConfidenceLevel.HIGH + + if blockers: + level = ConfidenceLevel.LOW + reasons.append(f"{len(blockers)} architecture blocker(s)") + elif sinks and sink_ratio < 0.2: + level = ConfidenceLevel.LOW + reasons.append(f"only {len(covered)}/{len(sinks)} sinks have tests that hit the changed radius") + elif sink_ratio < 0.85 or warnings or unresolved_ratio > 0.35: + level = ConfidenceLevel.MEDIUM + if sink_ratio < 0.85: + reasons.append(f"tests cover {len(covered)}/{len(sinks)} sinks") + if warnings: + reasons.append(f"{len(warnings)} architecture warning(s)") + if unresolved_ratio > 0.35: + reasons.append(f"{len(inferred)} inferred/unresolved edges on the path") + else: + reasons.append(f"tests cover {len(covered)}/{len(sinks) or 0} sinks") + reasons.append("no architecture blockers") + reasons.append("edges resolved") + + if not contract_ok and level == ConfidenceLevel.HIGH: + level = ConfidenceLevel.MEDIUM + reasons.append("contract drift between serializer and client/form") + + if residuals and level == ConfidenceLevel.HIGH: + level = ConfidenceLevel.MEDIUM + reasons.append(f"{len(residuals)} residual dynamic/inferred item(s)") + + while len(reasons) < 3: + if unresolved_ratio > 0: + reasons.append(f"{len(inferred)} inferred edges remain") + elif contract_nodes: + reasons.append(f"{len(contract_nodes)} contract node(s) in radius") + else: + reasons.append("impact radius is small") + if len(reasons) >= 3: + break + + return { + "level": level.value, + "score": round(sink_ratio - 0.15 * len(blockers) - 0.05 * unresolved_ratio, 3), + "reasons": reasons[:3], + "sinks": len(sinks), + "covered_sinks": len(covered), + "sink_ratio": round(sink_ratio, 3), + "blockers": len(blockers), + "warnings": len(warnings), + "inferred_edges": len(inferred), + "unresolved_ratio": round(unresolved_ratio, 3), + "contract_ok": contract_ok, + "tested_ids": sorted(tested_ids), + "sink_ids": [s["id"] for s in sinks], + "untested_sinks": [ + {"id": s["id"], "name": s["name"], "type": s["type"], "file_path": s.get("file_path")} + for s in sinks + if not reachable_tested(s["id"]) + ], + } diff --git a/src/loadpath/review/diff.py b/src/loadpath/review/diff.py new file mode 100644 index 0000000..8bcf4b2 --- /dev/null +++ b/src/loadpath/review/diff.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +from loadpath.types import GENERATED_PATH_MARKERS + + +@dataclass +class FileDiff: + path: str + status: str # A, M, D + added: int = 0 + deleted: int = 0 + patch: str = "" + skip: bool = False + + +@dataclass +class DiffSet: + files: list[FileDiff] = field(default_factory=list) + base: str = "HEAD" + head: str = "WORKTREE" + + @property + def paths(self) -> list[str]: + return [f.path for f in self.files if not f.skip] + + +def is_skippable(path: str) -> bool: + n = path.replace("\\", "/") + if n.endswith(("lock", ".min.js", ".map", ".svg", ".png", ".jpg", ".woff", ".woff2")): + return True + return any(m in n for m in GENERATED_PATH_MARKERS if m != "generated/") + + +def git_diff(repo_root: Path, base: str, head: str | None = None) -> DiffSet: + repo_root = repo_root.resolve() + args = ["git", "-C", str(repo_root), "diff", "--numstat", "-M", base] + if head: + args.append(head) + numstat = subprocess.check_output(args, text=True, stderr=subprocess.DEVNULL) + name_args = ["git", "-C", str(repo_root), "diff", "--name-status", "-M", base] + if head: + name_args.append(head) + namestat = subprocess.check_output(name_args, text=True, stderr=subprocess.DEVNULL) + patch_args = ["git", "-C", str(repo_root), "diff", "-U3", base] + if head: + patch_args.append(head) + patch = subprocess.check_output(patch_args, text=True, stderr=subprocess.DEVNULL, errors="replace") + patches = _split_patches(patch) + + added_map: dict[str, tuple[int, int]] = {} + for line in numstat.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) < 3: + continue + a, d, path = parts[0], parts[1], parts[2] + added_map[path] = (int(a) if a.isdigit() else 0, int(d) if d.isdigit() else 0) + + files: list[FileDiff] = [] + for line in namestat.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0][0] + path = parts[-1] + a, d = added_map.get(path, (0, 0)) + files.append( + FileDiff( + path=path, + status=status, + added=a, + deleted=d, + patch=patches.get(path, ""), + skip=is_skippable(path), + ) + ) + return DiffSet(files=files, base=base, head=head or "WORKTREE") + + +def _split_patches(patch: str) -> dict[str, str]: + out: dict[str, str] = {} + current: list[str] = [] + path = None + for line in patch.splitlines(keepends=True): + if line.startswith("diff --git "): + if path and current: + out[path] = "".join(current) + current = [line] + # diff --git a/foo b/foo + bits = line.strip().split(" ") + path = bits[-1][2:] if bits[-1].startswith("b/") else bits[-1] + else: + current.append(line) + if path and current: + out[path] = "".join(current) + return out diff --git a/src/loadpath/review/engine.py b/src/loadpath/review/engine.py new file mode 100644 index 0000000..b012d47 --- /dev/null +++ b/src/loadpath/review/engine.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +from loadpath.architecture.rules import evaluate +from loadpath.config import LoadpathConfig, load_config +from loadpath.graph.store import GraphStore +from loadpath.index import default_db_path, index_repo +from loadpath.review.cluster import cluster_diff +from loadpath.review.confidence import score_confidence +from loadpath.review.diff import DiffSet, git_diff +from loadpath.types import ( + ChangeKind, + GENERATED_PATH_MARKERS, + NodeType, +) + +READ_ORDER = [ + NodeType.SERIALIZER, + NodeType.SERIALIZER_FIELD, + NodeType.ROUTE, + NodeType.OPENAPI_PATH, + NodeType.FORM_SCHEMA, + NodeType.PERMISSION, + NodeType.VIEW, + NodeType.VIEWSET_ACTION, + NodeType.SERVICE, + NodeType.MODEL, + NodeType.FIELD, + NodeType.MIGRATION_OP, + NodeType.RECEIVER, + NodeType.SIGNAL, + NodeType.TASK, + NodeType.PAGE, + NodeType.HOOK, + NodeType.API_CLIENT, + NodeType.QUERY_KEY, + NodeType.COMPONENT, + NodeType.TEST, + NodeType.REACT_TEST, +] + + +def classify_change(impact_nodes: list[dict], findings: list, seeds: list[dict] | None = None) -> list[str]: + kinds: set[str] = set() + types = {n["type"] for n in impact_nodes} + seed_types = {n["type"] for n in (seeds or [])} or types + if ( + NodeType.SERIALIZER.value in seed_types + or NodeType.SERIALIZER_FIELD.value in seed_types + or NodeType.OPENAPI_PATH.value in seed_types + or NodeType.FORM_SCHEMA.value in seed_types + or NodeType.ROUTE.value in seed_types + ): + kinds.add(ChangeKind.PUBLIC_CONTRACT.value) + if ( + NodeType.MIGRATION_OP.value in seed_types + or NodeType.MODEL.value in seed_types + or NodeType.FIELD.value in seed_types + ): + kinds.add(ChangeKind.SCHEMA_MIGRATION.value) + if NodeType.PERMISSION.value in seed_types: + kinds.add(ChangeKind.AUTH.value) + if any(getattr(f, "rule", "") in { + "views_cannot_import_other_context_models", + "react_feature_may_only_call_own_or_shared_api", + } and not getattr(f, "waived", False) for f in findings): + kinds.add(ChangeKind.CROSS_CONTEXT.value) + if NodeType.SERVICE.value in types and ChangeKind.PUBLIC_CONTRACT.value not in kinds: + kinds.add(ChangeKind.INTERNAL_SERVICE.value) + ui_only = types <= { + NodeType.COMPONENT.value, + NodeType.PAGE.value, + NodeType.REACT_ROUTE.value, + NodeType.REACT_TEST.value, + NodeType.FEATURE_MODULE.value, + } + if ui_only and NodeType.API_CLIENT.value not in types: + kinds.add(ChangeKind.LEAF_UI.value) + if NodeType.COMPONENT.value in types or NodeType.PAGE.value in types: + if ChangeKind.PUBLIC_CONTRACT.value in kinds or NodeType.HOOK.value in types: + pass + elif ChangeKind.LEAF_UI.value not in kinds and len(kinds) == 0: + kinds.add(ChangeKind.LEAF_UI.value) + if not kinds: + kinds.add(ChangeKind.INTERNAL_SERVICE.value) + if len(kinds) > 1 and ChangeKind.LEAF_UI.value in kinds: + kinds.discard(ChangeKind.LEAF_UI.value) + return sorted(kinds) + + +def read_order_files(diff: DiffSet, impact_nodes: list[dict]) -> tuple[list[dict], list[str]]: + nodes_by_file: dict[str, list[dict]] = {} + for n in impact_nodes: + if n.get("file_path"): + nodes_by_file.setdefault(n["file_path"], []).append(n) + rank = {t: i for i, t in enumerate(READ_ORDER)} + + def file_rank(path: str) -> int: + nodes = nodes_by_file.get(path, []) + if not nodes: + return 50 + return min(rank.get(NodeType(n["type"]), 40) if n["type"] in {t.value for t in NodeType} else 40 for n in nodes) + + read: list[dict] = [] + skip: list[str] = [] + for f in diff.files: + if f.skip or any(m in f.path for m in GENERATED_PATH_MARKERS if m != "generated/"): + skip.append(f.path) + continue + nodes = nodes_by_file.get(f.path, []) + if not nodes and f.path.endswith((".md", ".txt", ".lock")): + skip.append(f.path) + continue + read.append( + { + "path": f.path, + "status": f.status, + "added": f.added, + "deleted": f.deleted, + "rank": file_rank(f.path), + "why": _why(nodes), + } + ) + read.sort(key=lambda r: (r["rank"], r["path"])) + return read, skip + + +def _why(nodes: list[dict]) -> str: + if not nodes: + return "changed file outside typed graph" + types = [n["type"].split(".")[-1] for n in nodes] + return ", ".join(sorted(set(types))[:4]) + + +def collect_residuals(store: GraphStore, impact_nodes: list[dict]) -> list[str]: + residuals = [] + tested = { + e["src"] + for e in store.edges() + if e["type"] == "tested_by" + } + for n in impact_nodes: + if n["type"] == NodeType.RECEIVER.value and n["id"] not in tested: + loc = f"{n.get('file_path')}:{n.get('start_line')}" if n.get("file_path") else n["qualified_name"] + residuals.append(f"{n['name']} ({loc}) — no test") + stored = store.get_meta("residuals") or "" + impact_files = {n.get("file_path") for n in impact_nodes if n.get("file_path")} + impact_names = {n.get("name") for n in impact_nodes} + for line in stored.splitlines(): + if any(f and f in line for f in impact_files) or any(n and str(n) in line for n in impact_names): + residuals.append(line) + for n in impact_nodes: + extra = n.get("extra") or {} + if extra.get("get_serializer_class"): + residuals.append(f"Dynamic get_serializer_class on {n['qualified_name']}") + if extra.get("string_ref"): + residuals.append(f"String model ref {n['qualified_name']}") + if extra.get("inferred"): + residuals.append(f"Inferred client {n.get('name')} in {n.get('file_path')}") + if extra.get("queryset_in_serializer"): + residuals.append(f"Queryset inside serializer {n['qualified_name']}") + seen = set() + out = [] + for r in residuals: + if r not in seen: + seen.add(r) + out.append(r) + return out + + +def suggested_reviewers(config: LoadpathConfig, impact_nodes: list[dict]) -> list[str]: + owners: list[str] = [] + for n in impact_nodes: + owners.extend(config.owners_for_context(n.get("context"))) + # unique preserve order + seen = set() + out = [] + for o in owners: + if o not in seen: + seen.add(o) + out.append(o) + return out + + +def is_low_risk(kinds: list[str], confidence: dict, findings: list) -> bool: + if any(not f.waived and f.severity.value == "blocker" for f in findings): + return False + if confidence["level"] == "low": + return False + if ChangeKind.CROSS_CONTEXT.value in kinds or ChangeKind.PUBLIC_CONTRACT.value in kinds: + return False + if ChangeKind.SCHEMA_MIGRATION.value in kinds or ChangeKind.AUTH.value in kinds: + return False + return confidence["level"] == "high" or ChangeKind.LEAF_UI.value in kinds + + +def run_review( + repo_root: Path, + base: str = "origin/main", + head: str | None = None, + db_path: Path | None = None, + config: LoadpathConfig | None = None, + diff: DiffSet | None = None, + reindex: bool = True, + incremental: bool = True, +) -> dict: + repo_root = repo_root.resolve() + config = config or load_config(repo_root) + graph_db = db_path or default_db_path(repo_root) + if reindex: + store = index_repo(repo_root, db_path=graph_db, config=config, incremental=incremental) + else: + if not graph_db.is_file(): + raise FileNotFoundError( + f"No index at {graph_db}. Run `loadpath index` or review with reindex enabled." + ) + store = GraphStore(graph_db) + + diff = diff or git_diff(repo_root, base, head) + clusters, impact_nodes, impact_edges = cluster_diff(store, diff) + seed_ids = {n["id"] for n in store.nodes_in_files(diff.paths)} + findings = evaluate(store, config, changed_ids=seed_ids) + # keep findings that touch the impact subgraph or changed files + impact_ids = {n["id"] for n in impact_nodes} + impact_files = set(diff.paths) + scoped = [ + f + for f in findings + if (f.node_id and f.node_id in impact_ids) + or (f.file_path and f.file_path in impact_files) + or not impact_ids + ] + residuals = collect_residuals(store, impact_nodes) + confidence = score_confidence(store, impact_nodes, impact_edges, scoped, residuals) + kinds = classify_change(impact_nodes, scoped, seeds=store.nodes_in_files(diff.paths)) + read, skip = read_order_files(diff, impact_nodes) + reviewers = suggested_reviewers(config, impact_nodes) + low_risk = is_low_risk(kinds, confidence, scoped) + labels = ["loadpath:" + confidence["level"]] + if low_risk: + labels.append("loadpath:low-risk") + if ChangeKind.CROSS_CONTEXT.value in kinds: + labels.append("loadpath:cross-context") + if ChangeKind.PUBLIC_CONTRACT.value in kinds: + labels.append("loadpath:contract") + + title = _title(clusters, kinds) + sinks = _sink_summaries(impact_nodes, store) + tests_note = _tests_note(confidence, impact_nodes) + arch_note = _arch_note(scoped, kinds, impact_nodes) + + payload = { + "id": str(uuid4()), + "created_at": datetime.now(timezone.utc).isoformat(), + "repo_root": str(repo_root), + "base": diff.base, + "head": diff.head, + "title": title, + "change_kinds": kinds, + "confidence": confidence, + "labels": labels, + "low_risk": low_risk, + "clusters": clusters, + "read_order": read, + "skip": skip, + "findings": [f.to_dict() for f in scoped], + "residuals": residuals, + "suggested_reviewers": reviewers, + "sinks": sinks, + "tests_note": tests_note, + "architecture_note": arch_note, + "nodes": impact_nodes, + "edges": impact_edges, + "counts": store.counts(), + "index": { + "db": str(store.db_path), + "counts": store.counts(), + "type_counts": store.type_counts(), + "indexed_at": store.get_meta("indexed_at"), + "reindexed": reindex, + "incremental": incremental if reindex else store.get_meta("incremental") == "1", + }, + "headline": _headline(confidence, title, sinks, tests_note, arch_note, residuals, reviewers), + } + store.save_review(payload["id"], payload["created_at"], str(repo_root), diff.base, diff.head, payload) + store.close() + return payload + + +def _title(clusters: list[dict], kinds: list[str]) -> str: + if clusters: + return clusters[0]["title"] + if kinds: + return kinds[0].replace("_", " ") + return "change" + + +def _sink_summaries(nodes: list[dict], store: GraphStore) -> list[dict]: + interesting = { + NodeType.ROUTE.value, + NodeType.OPENAPI_PATH.value, + NodeType.TASK.value, + NodeType.PAGE.value, + NodeType.FORM_SCHEMA.value, + NodeType.PERMISSION.value, + NodeType.MIGRATION_OP.value, + NodeType.RECEIVER.value, + } + out = [] + for n in nodes: + if n["type"] in interesting: + extra = n.get("extra") or {} + name = extra.get("mounted_at") or extra.get("full_path") or n["name"] + item = { + "id": n["id"], + "type": n["type"], + "name": name, + "file_path": n.get("file_path"), + "context": n.get("context"), + } + if extra.get("broker"): + item["broker"] = extra["broker"] + out.append(item) + return out + + +def _tests_note(confidence: dict, nodes: list[dict]) -> str: + untested = confidence.get("untested_sinks") or [] + covered = confidence.get("covered_sinks", 0) + sinks = confidence.get("sinks", 0) + if not untested: + return f"pytest/RTL hits {covered}/{sinks} sinks in the radius" + names = ", ".join(u["name"] for u in untested[:4]) + return f"{covered}/{sinks} sinks tested; missing tests on {names}" + + +def _arch_note(findings: list, kinds: list[str], impact_nodes: list[dict] | None = None) -> str: + blockers = [f for f in findings if not f.waived and f.severity.value == "blocker"] + if blockers: + return blockers[0].message + if ChangeKind.CROSS_CONTEXT.value in kinds: + return "cross-context edges present" + ctxs = sorted({n.get("context") for n in (impact_nodes or []) if n.get("context")}) + if ctxs: + return "stays inside " + " / ".join(ctxs) + return "no cross-context rule hits" + + +def _headline(confidence, title, sinks, tests_note, arch_note, residuals, reviewers) -> str: + sink_bits = [] + routes = [s["name"] for s in sinks if s["type"] == NodeType.ROUTE.value] + celery_tasks = [s["name"] for s in sinks if s["type"] == NodeType.TASK.value and s.get("broker") == "celery"] + dramatiq_tasks = [s["name"] for s in sinks if s["type"] == NodeType.TASK.value and s.get("broker") == "dramatiq"] + other_tasks = [ + s["name"] + for s in sinks + if s["type"] == NodeType.TASK.value and s.get("broker") not in {"celery", "dramatiq"} + ] + pages = [s["name"] for s in sinks if s["type"] in {NodeType.PAGE.value, NodeType.FORM_SCHEMA.value}] + if routes: + sink_bits.append(", ".join(routes[:4])) + if celery_tasks: + sink_bits.append("Celery " + ", ".join(celery_tasks[:3])) + if dramatiq_tasks: + sink_bits.append("Dramatiq " + ", ".join(dramatiq_tasks[:3])) + if other_tasks: + sink_bits.append("async " + ", ".join(other_tasks[:3])) + if pages: + sink_bits.append("React " + ", ".join(pages[:4])) + residual = residuals[0] if residuals else "none" + owners = ", ".join(reviewers) if reviewers else "context owners" + return ( + f"Loadpath: {confidence['level'].upper()} — {title}\n" + f"Sinks: {'; '.join(sink_bits) or 'none typed'}\n" + f"Tests: {tests_note}\n" + f"Architecture: {arch_note}\n" + f"Residual: {residual}\n" + f"Suggested reviewers: {owners}" + ) diff --git a/src/loadpath/review/render.py b/src/loadpath/review/render.py new file mode 100644 index 0000000..ad37454 --- /dev/null +++ b/src/loadpath/review/render.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "report" + + +def render_markdown(review: dict) -> str: + conf = review["confidence"] + lines = [ + f"## Loadpath: {conf['level'].upper()} — {review['title']}", + "", + review["headline"], + "", + "### What kind of change is this?", + ", ".join(k.replace("_", " ") for k in review["change_kinds"]) or "unknown", + "", + "### Read this, skip that", + ] + for item in review["read_order"]: + lines.append(f"- `{item['path']}` — {item['why']}") + if review["skip"]: + lines.append("") + lines.append("Skip: " + ", ".join(f"`{p}`" for p in review["skip"][:20])) + lines += ["", "### Clusters"] + for c in review["clusters"]: + files = ", ".join(f"`{f}`" for f in c["files"][:12]) + lines.append(f"- **{c['title']}** ({', '.join(c['contexts']) or 'unscoped'}): {files}") + lines += ["", "### Architecture"] + findings = review["findings"] + active = [f for f in findings if not f.get("waived")] + if not active: + lines.append("No rule hits on the impact path.") + for f in active: + flag = "BLOCKER" if f["severity"] == "blocker" else "warn" + lines.append(f"- [{flag}] `{f['rule']}`: {f['message']}") + lines += ["", "### Confidence"] + for r in conf["reasons"]: + lines.append(f"- {r}") + if review["residuals"]: + lines += ["", "### Residual uncertainty (AI-eligible)"] + for r in review["residuals"][:12]: + lines.append(f"- {r}") + if review["low_risk"]: + lines += ["", "_Fast-track: `loadpath:low-risk`. Human review can be a glance._"] + index = review.get("index") or {} + counts = index.get("counts") or review.get("counts") or {} + if counts: + mode = "incremental" if index.get("incremental") else "full" + lines += [ + "", + "### Index", + f"{counts.get('nodes', 0)} nodes / {counts.get('edges', 0)} edges" + + (f" · {mode}" if index else "") + + (f" · {index['indexed_at']}" if index.get("indexed_at") else ""), + ] + return "\n".join(lines) + "\n" + + +def render_html(review: dict) -> str: + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + autoescape=select_autoescape(["html"]), + ) + template = env.get_template("graph.html") + return template.render(review=review, markdown=render_markdown(review)) diff --git a/src/loadpath/server/__init__.py b/src/loadpath/server/__init__.py new file mode 100644 index 0000000..ee38a59 --- /dev/null +++ b/src/loadpath/server/__init__.py @@ -0,0 +1,3 @@ +from loadpath.server.app import app, create_app, serve + +__all__ = ["app", "create_app", "serve"] diff --git a/src/loadpath/server/app.py b/src/loadpath/server/app.py new file mode 100644 index 0000000..5c61cd4 --- /dev/null +++ b/src/loadpath/server/app.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import webbrowser +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from loadpath import __version__ +from loadpath.ai.providers import client_for, residual_prompt +from loadpath.architecture.snapshot import architecture_graph, architecture_report, summarize_index +from loadpath.config import load_config +from loadpath.graph.store import GraphStore +from loadpath.index import default_db_path, index_repo +from loadpath.providers.scm import provider_for +from loadpath.review.engine import run_review +from loadpath.review.render import render_html, render_markdown +from loadpath.settings import AppSettings, public_settings, register_workspace, settings_path, _should_update_secret + + +class IndexRequest(BaseModel): + repo_path: str + incremental: bool = True + + +class ReviewRequest(BaseModel): + repo_path: str + base: str = "origin/main" + head: str | None = None + reindex: bool = True + incremental: bool = True + + +class SettingsUpdate(BaseModel): + github_token: str | None = None + bitbucket_token: str | None = None + bitbucket_username: str | None = None + bitbucket_workspace: str | None = None + ai_provider: str | None = None + ai_api_key: str | None = None + ai_model: str | None = None + ai_base_url: str | None = None + workspaces: list[dict[str, Any]] | None = None + + +class PRListRequest(BaseModel): + provider: str + repo: str + state: str = "open" + token: str | None = None + username: str | None = None + + +class ResidualRequest(BaseModel): + review: dict[str, Any] = Field(default_factory=dict) + + +def create_app() -> FastAPI: + app = FastAPI(title="Loadpath", version=__version__) + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://127.0.0.1:7345", + "http://localhost:7345", + "http://127.0.0.1:5173", + "http://localhost:5173", + ], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/api/health") + def health() -> dict[str, str]: + return {"status": "ok", "version": __version__} + + @app.get("/api/settings") + def get_settings() -> dict[str, Any]: + return public_settings(AppSettings.load()) + + @app.put("/api/settings") + def put_settings(body: SettingsUpdate) -> dict[str, Any]: + current = AppSettings.load() + if _should_update_secret(body.github_token, current.github_token): + current.github_token = body.github_token or "" + if _should_update_secret(body.bitbucket_token, current.bitbucket_token): + current.bitbucket_token = body.bitbucket_token or "" + if body.bitbucket_username is not None: + current.bitbucket_username = body.bitbucket_username + if body.bitbucket_workspace is not None: + current.bitbucket_workspace = body.bitbucket_workspace + if body.ai_provider is not None: + current.ai.provider = body.ai_provider + if _should_update_secret(body.ai_api_key, current.ai.api_key): + current.ai.api_key = body.ai_api_key or "" + if body.ai_model is not None: + current.ai.model = body.ai_model + if body.ai_base_url is not None: + current.ai.base_url = body.ai_base_url + if body.workspaces is not None: + from loadpath.settings import Workspace + + current.workspaces = [Workspace.model_validate(w) for w in body.workspaces] + current.save() + return public_settings(current) + + @app.post("/api/index") + def api_index(body: IndexRequest) -> dict[str, Any]: + root = Path(body.repo_path).expanduser().resolve() + if not root.is_dir(): + raise HTTPException(404, f"Repo not found: {root}") + store = index_repo(root, incremental=body.incremental) + register_workspace(root) + summary = summarize_index(store, load_config(root)) + store.close() + return summary + + @app.get("/api/index") + def api_index_status(repo_path: str) -> dict[str, Any]: + root = Path(repo_path).expanduser().resolve() + if not root.is_dir(): + raise HTTPException(404, f"Repo not found: {root}") + report = architecture_report(root) + report.pop("nodes", None) + report.pop("edges", None) + return report + + @app.get("/api/repos") + def api_repos() -> dict[str, Any]: + settings = AppSettings.load() + repos = [] + for workspace in settings.workspaces: + path = Path(workspace.path) + item: dict[str, Any] = { + "path": workspace.path, + "name": workspace.name or path.name, + "exists": path.is_dir(), + "indexed": False, + "counts": {"nodes": 0, "edges": 0}, + } + if path.is_dir(): + report = architecture_report(path) + item.update( + { + "indexed": report["indexed"], + "counts": report.get("counts") or {"nodes": 0, "edges": 0}, + "indexed_at": report.get("indexed_at"), + "contexts": list((report.get("contexts") or {}).keys()), + "has_config": report.get("has_config", False), + } + ) + repos.append(item) + return {"repos": repos} + + @app.post("/api/review") + def api_review(body: ReviewRequest) -> dict[str, Any]: + root = Path(body.repo_path).expanduser().resolve() + if not root.is_dir(): + raise HTTPException(404, f"Repo not found: {root}") + try: + review = run_review( + root, + base=body.base, + head=body.head, + reindex=body.reindex, + incremental=body.incremental, + ) + except FileNotFoundError as exc: + raise HTTPException(409, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(400, str(exc)) from exc + review["markdown"] = render_markdown(review) + register_workspace(root) + return review + + @app.get("/api/reviews") + def api_reviews(repo_path: str) -> dict[str, Any]: + root = Path(repo_path).expanduser().resolve() + db = default_db_path(root) + if not db.is_file(): + return {"reviews": []} + store = GraphStore(db) + items = store.list_reviews() + store.close() + return {"reviews": items} + + @app.get("/api/reviews/{review_id}") + def api_review_get(review_id: str, repo_path: str) -> dict[str, Any]: + root = Path(repo_path).expanduser().resolve() + store = GraphStore(default_db_path(root)) + item = store.get_review(review_id) + store.close() + if not item: + raise HTTPException(404, "Review not found") + return item + + @app.get("/api/graph") + def api_graph(repo_path: str, scope: str = "full") -> dict[str, Any]: + root = Path(repo_path).expanduser().resolve() + db = default_db_path(root) + if not db.is_file(): + raise HTTPException(409, "Index the repo first") + store = GraphStore(db) + if scope == "architecture": + nodes, edges = architecture_graph(store) + else: + nodes, edges = store.nodes(), store.edges() + payload = {"nodes": nodes, "edges": edges, "counts": store.counts(), "scope": scope} + store.close() + return payload + + @app.get("/api/architecture") + def api_architecture(repo_path: str) -> dict[str, Any]: + root = Path(repo_path).expanduser().resolve() + if not root.is_dir(): + raise HTTPException(404, f"Repo not found: {root}") + return architecture_report(root) + + @app.get("/api/config") + def api_config(repo_path: str) -> dict[str, Any]: + root = Path(repo_path).expanduser().resolve() + cfg = load_config(root) + return { + "contexts": {k: vars(v) for k, v in cfg.contexts.items()}, + "rules": cfg.rules, + "django_root": cfg.django_root, + "react_root": cfg.react_root, + } + + @app.post("/api/prs") + def api_prs(body: PRListRequest) -> dict[str, Any]: + settings = AppSettings.load() + token = body.token + username = body.username or settings.bitbucket_username + if not token: + token = settings.github_token if body.provider == "github" else settings.bitbucket_token + if not token: + raise HTTPException(400, f"No {body.provider} token configured") + try: + scm = provider_for(body.provider, token, username=username) + prs = scm.list_pull_requests(body.repo, state=body.state) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, str(exc)) from exc + return {"pull_requests": [p.to_dict() for p in prs]} + + @app.post("/api/ai/residual") + def api_residual(body: ResidualRequest) -> dict[str, Any]: + settings = AppSettings.load() + if settings.ai.provider in {"", "none"} or not settings.ai.api_key: + raise HTTPException(400, "Configure an AI provider in settings first") + try: + client = client_for( + settings.ai.provider, + settings.ai.api_key, + model=settings.ai.model, + base_url=settings.ai.base_url, + ) + text = client.complete(residual_prompt(body.review)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, str(exc)) from exc + return {"note": text} + + static_dir = Path(__file__).resolve().parent.parent / "static" + if static_dir.is_dir(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") + + return app + + +app = create_app() + + +def serve(host: str = "127.0.0.1", port: int = 7345, open_browser: bool = True) -> None: + import uvicorn + + settings_path().parent.mkdir(parents=True, exist_ok=True) + if open_browser: + webbrowser.open(f"http://{host}:{port}") + uvicorn.run("loadpath.server.app:app", host=host, port=port, reload=False) diff --git a/src/loadpath/settings.py b/src/loadpath/settings.py new file mode 100644 index 0000000..f8c142a --- /dev/null +++ b/src/loadpath/settings.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + +_SETTINGS_LOCK = threading.RLock() + + +def settings_path() -> Path: + return Path.home() / ".loadpath" / "settings.json" + + +class AISettings(BaseModel): + provider: str = "none" + api_key: str = "" + model: str = "" + base_url: str = "" + + +class SCMConnection(BaseModel): + provider: str # github | bitbucket + token: str = "" + username: str = "" + workspace: str = "" + repos: list[str] = Field(default_factory=list) + + +class Workspace(BaseModel): + path: str + name: str = "" + remote: str | None = None + + +class AppSettings(BaseModel): + github_token: str = "" + bitbucket_token: str = "" + bitbucket_username: str = "" + bitbucket_workspace: str = "" + ai: AISettings = Field(default_factory=AISettings) + workspaces: list[Workspace] = Field(default_factory=list) + connections: list[SCMConnection] = Field(default_factory=list) + + def save(self, path: Path | None = None) -> None: + with _SETTINGS_LOCK: + self._write(path) + + def _write(self, path: Path | None = None) -> None: + p = path or settings_path() + p.parent.mkdir(parents=True, exist_ok=True) + try: + os.chmod(p.parent, 0o700) + except OSError: + pass + payload = self.model_dump_json(indent=2) + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.write_text(payload, encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(p) + try: + os.chmod(p, 0o600) + except OSError: + pass + + @classmethod + def load(cls, path: Path | None = None) -> AppSettings: + p = path or settings_path() + if not p.is_file(): + return cls() + data = json.loads(p.read_text(encoding="utf-8")) + return cls.model_validate(data) + + +def mask_secret(value: str) -> str: + if not value: + return "" + if len(value) <= 8: + return "••••" + return value[:4] + "…" + value[-2:] + + +def _should_update_secret(value: str | None, current: str) -> bool: + if value is None or value == "" or "…" in value: + return False + return value != current + + +def register_workspace(path: Path, name: str | None = None) -> AppSettings: + with _SETTINGS_LOCK: + settings = AppSettings.load() + resolved = str(path.expanduser().resolve()) + if not any(w.path == resolved for w in settings.workspaces): + settings.workspaces.append(Workspace(path=resolved, name=name or Path(resolved).name)) + settings._write() + return settings + + +def public_settings(settings: AppSettings) -> dict[str, Any]: + data = settings.model_dump() + data["github_token"] = mask_secret(settings.github_token) + data["bitbucket_token"] = mask_secret(settings.bitbucket_token) + data["github_token_set"] = bool(settings.github_token) + data["bitbucket_token_set"] = bool(settings.bitbucket_token) + data["ai"] = { + "provider": settings.ai.provider, + "model": settings.ai.model, + "base_url": settings.ai.base_url, + "api_key_set": bool(settings.ai.api_key), + "api_key": mask_secret(settings.ai.api_key), + } + for conn in data.get("connections") or []: + token = conn.get("token") or "" + conn["token_set"] = bool(token) + conn["token"] = mask_secret(token) + return data diff --git a/src/loadpath/static/assets/index-BCwS4krr.css b/src/loadpath/static/assets/index-BCwS4krr.css new file mode 100644 index 0000000..7f56e4e --- /dev/null +++ b/src/loadpath/static/assets/index-BCwS4krr.css @@ -0,0 +1 @@ +.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #555;--xy-background-pattern-lines-color-default: #333;--xy-background-pattern-cross-color-default: #333;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}:root{--bg: #070b10;--bg-2: #0d141c;--surface: #121a24;--line: #1e2c3c;--ink: #e7eef6;--muted: #8b9bb0;--high: #2a9d8f;--medium: #e9c46a;--low: #e76f51;--critical: #e85d04;--accent: #4cc9f0}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--ink);font-family:IBM Plex Sans,Segoe UI,sans-serif}button,input,select,textarea{font-family:inherit;color:inherit}.app{display:grid;grid-template-columns:220px 1fr;height:100%}.rail{border-right:1px solid var(--line);background:linear-gradient(180deg,#0b1219,#070b10);padding:20px 14px;display:flex;flex-direction:column;gap:6px}.brand{font-family:IBM Plex Mono,monospace;letter-spacing:.18em;font-size:12px;text-transform:uppercase;color:var(--accent);margin:0 0 18px}.rail button,.tab{background:transparent;border:0;text-align:left;padding:8px 10px;border-radius:6px;color:var(--muted);cursor:pointer}.rail button.active,.tab.active{background:#15202c;color:var(--ink)}.main{display:flex;flex-direction:column;min-width:0}.topbar{display:flex;gap:8px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:var(--bg-2)}.topbar input,.topbar select{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:8px 10px;min-width:160px}.topbar input.path{flex:1}.topbar button,.btn{background:#173044;border:1px solid #24506c;border-radius:6px;padding:8px 12px;cursor:pointer}.btn.primary{background:#134e4a;border-color:#2a9d8f}.content{flex:1;min-height:0;display:grid;grid-template-columns:420px 1fr}.brief{overflow:auto;border-right:1px solid var(--line);padding:18px;background:radial-gradient(circle at 0 0,rgba(76,201,240,.05),transparent 40%),var(--bg)}.graph-wrap{position:relative;min-height:0}.graph-wrap .react-flow{background-color:#070b10;background-image:linear-gradient(rgba(42,80,120,.09) 1px,transparent 1px),linear-gradient(90deg,rgba(42,80,120,.09) 1px,transparent 1px);background-size:24px 24px}h1{font-size:18px;margin:0 0 6px}.level{font-family:IBM Plex Mono,monospace;font-weight:600}.level.high{color:var(--high)}.level.medium{color:var(--medium)}.level.low{color:var(--low)}.kicker{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin:16px 0 6px}.chip{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);margin:0 6px 6px 0;color:var(--muted)}.chip.blocker{color:var(--low);border-color:var(--low)}.file{font-family:IBM Plex Mono,monospace;font-size:12px;color:var(--accent)}.muted{color:var(--muted);font-size:13px;line-height:1.45}.error{color:var(--low);padding:8px 16px}.pr-list{padding:16px;overflow:auto}.pr{border:1px solid var(--line);background:var(--surface);padding:12px 14px;border-radius:8px;margin-bottom:10px}.pr h3{margin:0 0 4px;font-size:14px}.settings{padding:24px;max-width:640px;display:grid;gap:10px}.settings label{font-size:12px;color:var(--muted)}.settings input,.settings select{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:8px 10px;width:100%}.headline{white-space:pre-wrap;font-family:IBM Plex Mono,monospace;font-size:12px;color:var(--muted)}.graph-modes{display:flex;gap:6px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--bg-2)}.graph-modes button{background:transparent;border:1px solid var(--line);border-radius:999px;padding:4px 10px;color:var(--muted);cursor:pointer}.graph-modes button.active{background:#15202c;color:var(--ink);border-color:#2a9d8f}.lp-node{padding:8px 10px;border-radius:8px;border:1px solid #2a3d52;background:#101822;min-width:150px;box-shadow:0 0 0 1px #4cc9f014}.lp-node .t{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}.lp-node .n{font-size:13px;font-weight:600} diff --git a/src/loadpath/static/assets/index-BlJPyDt6.js b/src/loadpath/static/assets/index-BlJPyDt6.js new file mode 100644 index 0000000..b60ce67 --- /dev/null +++ b/src/loadpath/static/assets/index-BlJPyDt6.js @@ -0,0 +1,62 @@ +(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const u of document.querySelectorAll('link[rel="modulepreload"]'))l(u);new MutationObserver(u=>{for(const c of u)if(c.type==="childList")for(const f of c.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&l(f)}).observe(document,{childList:!0,subtree:!0});function o(u){const c={};return u.integrity&&(c.integrity=u.integrity),u.referrerPolicy&&(c.referrerPolicy=u.referrerPolicy),u.crossOrigin==="use-credentials"?c.credentials="include":u.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function l(u){if(u.ep)return;u.ep=!0;const c=o(u);fetch(u.href,c)}})();function Zh(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var ha={exports:{}},Gi={},pa={exports:{}},Ne={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var jd;function w0(){if(jd)return Ne;jd=1;var t=Symbol.for("react.element"),r=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),l=Symbol.for("react.strict_mode"),u=Symbol.for("react.profiler"),c=Symbol.for("react.provider"),f=Symbol.for("react.context"),h=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),m=Symbol.for("react.lazy"),v=Symbol.iterator;function g(C){return C===null||typeof C!="object"?null:(C=v&&C[v]||C["@@iterator"],typeof C=="function"?C:null)}var w={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},S=Object.assign,M={};function N(C,z,ne){this.props=C,this.context=z,this.refs=M,this.updater=ne||w}N.prototype.isReactComponent={},N.prototype.setState=function(C,z){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,z,"setState")},N.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function E(){}E.prototype=N.prototype;function R(C,z,ne){this.props=C,this.context=z,this.refs=M,this.updater=ne||w}var _=R.prototype=new E;_.constructor=R,S(_,N.prototype),_.isPureReactComponent=!0;var I=Array.isArray,W=Object.prototype.hasOwnProperty,$={current:null},V={key:!0,ref:!0,__self:!0,__source:!0};function X(C,z,ne){var ie,ce={},O=null,re=null;if(z!=null)for(ie in z.ref!==void 0&&(re=z.ref),z.key!==void 0&&(O=""+z.key),z)W.call(z,ie)&&!V.hasOwnProperty(ie)&&(ce[ie]=z[ie]);var Q=arguments.length-2;if(Q===1)ce.children=ne;else if(1>>1,z=A[C];if(0>>1;Cu(ce,F))Ou(re,ce)?(A[C]=re,A[O]=F,C=O):(A[C]=ce,A[ie]=F,C=ie);else if(Ou(re,F))A[C]=re,A[O]=F,C=O;else break e}}return j}function u(A,j){var F=A.sortIndex-j.sortIndex;return F!==0?F:A.id-j.id}if(typeof performance=="object"&&typeof performance.now=="function"){var c=performance;t.unstable_now=function(){return c.now()}}else{var f=Date,h=f.now();t.unstable_now=function(){return f.now()-h}}var p=[],y=[],m=1,v=null,g=3,w=!1,S=!1,M=!1,N=typeof setTimeout=="function"?setTimeout:null,E=typeof clearTimeout=="function"?clearTimeout:null,R=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function _(A){for(var j=o(y);j!==null;){if(j.callback===null)l(y);else if(j.startTime<=A)l(y),j.sortIndex=j.expirationTime,r(p,j);else break;j=o(y)}}function I(A){if(M=!1,_(A),!S)if(o(p)!==null)S=!0,H(W);else{var j=o(y);j!==null&&U(I,j.startTime-A)}}function W(A,j){S=!1,M&&(M=!1,E(X),X=-1),w=!0;var F=g;try{for(_(j),v=o(p);v!==null&&(!(v.expirationTime>j)||A&&!Z());){var C=v.callback;if(typeof C=="function"){v.callback=null,g=v.priorityLevel;var z=C(v.expirationTime<=j);j=t.unstable_now(),typeof z=="function"?v.callback=z:v===o(p)&&l(p),_(j)}else l(p);v=o(p)}if(v!==null)var ne=!0;else{var ie=o(y);ie!==null&&U(I,ie.startTime-j),ne=!1}return ne}finally{v=null,g=F,w=!1}}var $=!1,V=null,X=-1,K=5,te=-1;function Z(){return!(t.unstable_now()-teA||125C?(A.sortIndex=F,r(y,A),o(p)===null&&A===o(y)&&(M?(E(X),X=-1):M=!0,U(I,F-C))):(A.sortIndex=z,r(p,A),S||w||(S=!0,H(W))),A},t.unstable_shouldYield=Z,t.unstable_wrapCallback=function(A){var j=g;return function(){var F=g;g=j;try{return A.apply(this,arguments)}finally{g=F}}}})(ya)),ya}var Fd;function N0(){return Fd||(Fd=1,ma.exports=k0()),ma.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Hd;function C0(){if(Hd)return gt;Hd=1;var t=fo(),r=N0();function o(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,i=1;i"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),p=Object.prototype.hasOwnProperty,y=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,m={},v={};function g(e){return p.call(v,e)?!0:p.call(m,e)?!1:y.test(e)?v[e]=!0:(m[e]=!0,!1)}function w(e,n,i,s){if(i!==null&&i.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return s?!1:i!==null?!i.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function S(e,n,i,s){if(n===null||typeof n>"u"||w(e,n,i,s))return!0;if(s)return!1;if(i!==null)switch(i.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function M(e,n,i,s,a,d,x){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=s,this.attributeNamespace=a,this.mustUseProperty=i,this.propertyName=e,this.type=n,this.sanitizeURL=d,this.removeEmptyString=x}var N={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){N[e]=new M(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];N[n]=new M(n,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){N[e]=new M(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){N[e]=new M(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){N[e]=new M(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){N[e]=new M(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){N[e]=new M(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){N[e]=new M(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){N[e]=new M(e,5,!1,e.toLowerCase(),null,!1,!1)});var E=/[\-:]([a-z])/g;function R(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(E,R);N[n]=new M(n,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(E,R);N[n]=new M(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(E,R);N[n]=new M(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){N[e]=new M(e,1,!1,e.toLowerCase(),null,!1,!1)}),N.xlinkHref=new M("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){N[e]=new M(e,1,!1,e.toLowerCase(),null,!0,!0)});function _(e,n,i,s){var a=N.hasOwnProperty(n)?N[n]:null;(a!==null?a.type!==0:s||!(2T||a[x]!==d[T]){var L=` +`+a[x].replace(" at new "," at ");return e.displayName&&L.includes("")&&(L=L.replace("",e.displayName)),L}while(1<=x&&0<=T);break}}}finally{ne=!1,Error.prepareStackTrace=i}return(e=e?e.displayName||e.name:"")?z(e):""}function ce(e){switch(e.tag){case 5:return z(e.type);case 16:return z("Lazy");case 13:return z("Suspense");case 19:return z("SuspenseList");case 0:case 2:case 15:return e=ie(e.type,!1),e;case 11:return e=ie(e.type.render,!1),e;case 1:return e=ie(e.type,!0),e;default:return""}}function O(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case V:return"Fragment";case $:return"Portal";case K:return"Profiler";case X:return"StrictMode";case J:return"Suspense";case k:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Z:return(e.displayName||"Context")+".Consumer";case te:return(e._context.displayName||"Context")+".Provider";case ee:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case B:return n=e.displayName||null,n!==null?n:O(e.type)||"Memo";case H:n=e._payload,e=e._init;try{return O(e(n))}catch{}}return null}function re(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return O(n);case 8:return n===X?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function Q(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function le(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function pe(e){var n=le(e)?"checked":"value",i=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),s=""+e[n];if(!e.hasOwnProperty(n)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var a=i.get,d=i.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return a.call(this)},set:function(x){s=""+x,d.call(this,x)}}),Object.defineProperty(e,n,{enumerable:i.enumerable}),{getValue:function(){return s},setValue:function(x){s=""+x},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function we(e){e._valueTracker||(e._valueTracker=pe(e))}function ve(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var i=n.getValue(),s="";return e&&(s=le(e)?e.checked?"true":"false":e.value),e=s,e!==i?(n.setValue(e),!0):!1}function me(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ke(e,n){var i=n.checked;return F({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:i??e._wrapperState.initialChecked})}function Ce(e,n){var i=n.defaultValue==null?"":n.defaultValue,s=n.checked!=null?n.checked:n.defaultChecked;i=Q(n.value!=null?n.value:i),e._wrapperState={initialChecked:s,initialValue:i,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function Me(e,n){n=n.checked,n!=null&&_(e,"checked",n,!1)}function Re(e,n){Me(e,n);var i=Q(n.value),s=n.type;if(i!=null)s==="number"?(i===0&&e.value===""||e.value!=i)&&(e.value=""+i):e.value!==""+i&&(e.value=""+i);else if(s==="submit"||s==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?ut(e,n.type,i):n.hasOwnProperty("defaultValue")&&ut(e,n.type,Q(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function ot(e,n,i){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var s=n.type;if(!(s!=="submit"&&s!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,i||n===e.value||(e.value=n),e.defaultValue=n}i=e.name,i!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,i!==""&&(e.name=i)}function ut(e,n,i){(n!=="number"||me(e.ownerDocument)!==e)&&(i==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+i&&(e.defaultValue=""+i))}var Je=Array.isArray;function et(e,n,i,s){if(e=e.options,n){n={};for(var a=0;a"+n.valueOf().toString()+"",n=Mt.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function ln(e,n){if(n){var i=e.firstChild;if(i&&i===e.lastChild&&i.nodeType===3){i.nodeValue=n;return}}e.textContent=n}var Yn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Sl=["Webkit","ms","Moz","O"];Object.keys(Yn).forEach(function(e){Sl.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),Yn[n]=Yn[e]})});function xo(e,n,i){return n==null||typeof n=="boolean"||n===""?"":i||typeof n!="number"||n===0||Yn.hasOwnProperty(e)&&Yn[e]?(""+n).trim():n+"px"}function wo(e,n){e=e.style;for(var i in n)if(n.hasOwnProperty(i)){var s=i.indexOf("--")===0,a=xo(i,n[i],s);i==="float"&&(i="cssFloat"),s?e.setProperty(i,a):e[i]=a}}var _l=F({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function si(e,n){if(n){if(_l[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(o(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(o(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(o(61))}if(n.style!=null&&typeof n.style!="object")throw Error(o(62))}}function li(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ui=null;function ai(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ci=null,wn=null,Sn=null;function So(e){if(e=ji(e)){if(typeof ci!="function")throw Error(o(280));var n=e.stateNode;n&&(n=qo(n),ci(e.stateNode,e.type,n))}}function _o(e){wn?Sn?Sn.push(e):Sn=[e]:wn=e}function Eo(){if(wn){var e=wn,n=Sn;if(Sn=wn=null,So(e),n)for(e=0;e>>=0,e===0?32:31-(Rl(e)/Ll|0)|0}var Nr=64,Cr=4194304;function Kn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function un(e,n){var i=e.pendingLanes;if(i===0)return 0;var s=0,a=e.suspendedLanes,d=e.pingedLanes,x=i&268435455;if(x!==0){var T=x&~a;T!==0?s=Kn(T):(d&=x,d!==0&&(s=Kn(d)))}else x=i&~a,x!==0?s=Kn(x):d!==0&&(s=Kn(d));if(s===0)return 0;if(n!==0&&n!==s&&(n&a)===0&&(a=s&-s,d=n&-n,a>=d||a===16&&(d&4194240)!==0))return n;if((s&4)!==0&&(s|=i&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=s;0i;i++)n.push(e);return n}function Zn(e,n,i){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-xt(n),e[n]=i}function $l(e,n){var i=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var s=e.eventTimes;for(e=e.expirationTimes;0=Ni),Cc=" ",Mc=!1;function Pc(e,n){switch(e){case"keyup":return gm.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ic(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Tr=!1;function ym(e,n){switch(e){case"compositionend":return Ic(n);case"keypress":return n.which!==32?null:(Mc=!0,Cc);case"textInput":return e=n.data,e===Cc&&Mc?null:e;default:return null}}function vm(e,n){if(Tr)return e==="compositionend"||!Xl&&Pc(e,n)?(e=wc(),Ho=Vl=Cn=null,Tr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:i,offset:n-e};e=s}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=$c(i)}}function Oc(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Oc(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Fc(){for(var e=window,n=me();n instanceof e.HTMLIFrameElement;){try{var i=typeof n.contentWindow.location.href=="string"}catch{i=!1}if(i)e=n.contentWindow;else break;n=me(e.document)}return n}function Kl(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function Mm(e){var n=Fc(),i=e.focusedElem,s=e.selectionRange;if(n!==i&&i&&i.ownerDocument&&Oc(i.ownerDocument.documentElement,i)){if(s!==null&&Kl(i)){if(n=s.start,e=s.end,e===void 0&&(e=n),"selectionStart"in i)i.selectionStart=n,i.selectionEnd=Math.min(e,i.value.length);else if(e=(n=i.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var a=i.textContent.length,d=Math.min(s.start,a);s=s.end===void 0?d:Math.min(s.end,a),!e.extend&&d>s&&(a=s,s=d,d=a),a=Dc(i,d);var x=Dc(i,s);a&&x&&(e.rangeCount!==1||e.anchorNode!==a.node||e.anchorOffset!==a.offset||e.focusNode!==x.node||e.focusOffset!==x.offset)&&(n=n.createRange(),n.setStart(a.node,a.offset),e.removeAllRanges(),d>s?(e.addRange(n),e.extend(x.node,x.offset)):(n.setEnd(x.node,x.offset),e.addRange(n)))}}for(n=[],e=i;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof i.focus=="function"&&i.focus(),i=0;i=document.documentMode,zr=null,ql=null,Ii=null,Zl=!1;function Hc(e,n,i){var s=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;Zl||zr==null||zr!==me(s)||(s=zr,"selectionStart"in s&&Kl(s)?s={start:s.selectionStart,end:s.selectionEnd}:(s=(s.ownerDocument&&s.ownerDocument.defaultView||window).getSelection(),s={anchorNode:s.anchorNode,anchorOffset:s.anchorOffset,focusNode:s.focusNode,focusOffset:s.focusOffset}),Ii&&Pi(Ii,s)||(Ii=s,s=Qo(ql,"onSelect"),0$r||(e.current=cu[$r],cu[$r]=null,$r--)}function Le(e,n){$r++,cu[$r]=e.current,e.current=n}var Tn={},tt=In(Tn),ct=In(!1),er=Tn;function Dr(e,n){var i=e.type.contextTypes;if(!i)return Tn;var s=e.stateNode;if(s&&s.__reactInternalMemoizedUnmaskedChildContext===n)return s.__reactInternalMemoizedMaskedChildContext;var a={},d;for(d in i)a[d]=n[d];return s&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=a),a}function ft(e){return e=e.childContextTypes,e!=null}function Zo(){Ae(ct),Ae(tt)}function tf(e,n,i){if(tt.current!==Tn)throw Error(o(168));Le(tt,n),Le(ct,i)}function nf(e,n,i){var s=e.stateNode;if(n=n.childContextTypes,typeof s.getChildContext!="function")return i;s=s.getChildContext();for(var a in s)if(!(a in n))throw Error(o(108,re(e)||"Unknown",a));return F({},i,s)}function Jo(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Tn,er=tt.current,Le(tt,e),Le(ct,ct.current),!0}function rf(e,n,i){var s=e.stateNode;if(!s)throw Error(o(169));i?(e=nf(e,n,er),s.__reactInternalMemoizedMergedChildContext=e,Ae(ct),Ae(tt),Le(tt,e)):Ae(ct),Le(ct,i)}var cn=null,es=!1,fu=!1;function of(e){cn===null?cn=[e]:cn.push(e)}function Fm(e){es=!0,of(e)}function zn(){if(!fu&&cn!==null){fu=!0;var e=0,n=ze;try{var i=cn;for(ze=1;e>=x,a-=x,fn=1<<32-xt(n)+a|i<Ee?(Ke=_e,_e=null):Ke=_e.sibling;var Te=oe(b,_e,Y[Ee],ae);if(Te===null){_e===null&&(_e=Ke);break}e&&_e&&Te.alternate===null&&n(b,_e),D=d(Te,D,Ee),Se===null?ye=Te:Se.sibling=Te,Se=Te,_e=Ke}if(Ee===Y.length)return i(b,_e),De&&nr(b,Ee),ye;if(_e===null){for(;EeEe?(Ke=_e,_e=null):Ke=_e.sibling;var Hn=oe(b,_e,Te.value,ae);if(Hn===null){_e===null&&(_e=Ke);break}e&&_e&&Hn.alternate===null&&n(b,_e),D=d(Hn,D,Ee),Se===null?ye=Hn:Se.sibling=Hn,Se=Hn,_e=Ke}if(Te.done)return i(b,_e),De&&nr(b,Ee),ye;if(_e===null){for(;!Te.done;Ee++,Te=Y.next())Te=ue(b,Te.value,ae),Te!==null&&(D=d(Te,D,Ee),Se===null?ye=Te:Se.sibling=Te,Se=Te);return De&&nr(b,Ee),ye}for(_e=s(b,_e);!Te.done;Ee++,Te=Y.next())Te=fe(_e,b,Ee,Te.value,ae),Te!==null&&(e&&Te.alternate!==null&&_e.delete(Te.key===null?Ee:Te.key),D=d(Te,D,Ee),Se===null?ye=Te:Se.sibling=Te,Se=Te);return e&&_e.forEach(function(x0){return n(b,x0)}),De&&nr(b,Ee),ye}function Ue(b,D,Y,ae){if(typeof Y=="object"&&Y!==null&&Y.type===V&&Y.key===null&&(Y=Y.props.children),typeof Y=="object"&&Y!==null){switch(Y.$$typeof){case W:e:{for(var ye=Y.key,Se=D;Se!==null;){if(Se.key===ye){if(ye=Y.type,ye===V){if(Se.tag===7){i(b,Se.sibling),D=a(Se,Y.props.children),D.return=b,b=D;break e}}else if(Se.elementType===ye||typeof ye=="object"&&ye!==null&&ye.$$typeof===H&&ff(ye)===Se.type){i(b,Se.sibling),D=a(Se,Y.props),D.ref=Ai(b,Se,Y),D.return=b,b=D;break e}i(b,Se);break}else n(b,Se);Se=Se.sibling}Y.type===V?(D=cr(Y.props.children,b.mode,ae,Y.key),D.return=b,b=D):(ae=Ps(Y.type,Y.key,Y.props,null,b.mode,ae),ae.ref=Ai(b,D,Y),ae.return=b,b=ae)}return x(b);case $:e:{for(Se=Y.key;D!==null;){if(D.key===Se)if(D.tag===4&&D.stateNode.containerInfo===Y.containerInfo&&D.stateNode.implementation===Y.implementation){i(b,D.sibling),D=a(D,Y.children||[]),D.return=b,b=D;break e}else{i(b,D);break}else n(b,D);D=D.sibling}D=ua(Y,b.mode,ae),D.return=b,b=D}return x(b);case H:return Se=Y._init,Ue(b,D,Se(Y._payload),ae)}if(Je(Y))return he(b,D,Y,ae);if(j(Y))return ge(b,D,Y,ae);is(b,Y)}return typeof Y=="string"&&Y!==""||typeof Y=="number"?(Y=""+Y,D!==null&&D.tag===6?(i(b,D.sibling),D=a(D,Y),D.return=b,b=D):(i(b,D),D=la(Y,b.mode,ae),D.return=b,b=D),x(b)):i(b,D)}return Ue}var Vr=df(!0),hf=df(!1),os=In(null),ss=null,Br=null,yu=null;function vu(){yu=Br=ss=null}function xu(e){var n=os.current;Ae(os),e._currentValue=n}function wu(e,n,i){for(;e!==null;){var s=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,s!==null&&(s.childLanes|=n)):s!==null&&(s.childLanes&n)!==n&&(s.childLanes|=n),e===i)break;e=e.return}}function Ur(e,n){ss=e,yu=Br=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&n)!==0&&(dt=!0),e.firstContext=null)}function Tt(e){var n=e._currentValue;if(yu!==e)if(e={context:e,memoizedValue:n,next:null},Br===null){if(ss===null)throw Error(o(308));Br=e,ss.dependencies={lanes:0,firstContext:e}}else Br=Br.next=e;return n}var rr=null;function Su(e){rr===null?rr=[e]:rr.push(e)}function pf(e,n,i,s){var a=n.interleaved;return a===null?(i.next=i,Su(n)):(i.next=a.next,a.next=i),n.interleaved=i,hn(e,s)}function hn(e,n){e.lanes|=n;var i=e.alternate;for(i!==null&&(i.lanes|=n),i=e,e=e.return;e!==null;)e.childLanes|=n,i=e.alternate,i!==null&&(i.childLanes|=n),i=e,e=e.return;return i.tag===3?i.stateNode:null}var Rn=!1;function _u(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function gf(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function pn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function Ln(e,n,i){var s=e.updateQueue;if(s===null)return null;if(s=s.shared,(Pe&2)!==0){var a=s.pending;return a===null?n.next=n:(n.next=a.next,a.next=n),s.pending=n,hn(e,i)}return a=s.interleaved,a===null?(n.next=n,Su(s)):(n.next=a.next,a.next=n),s.interleaved=n,hn(e,i)}function ls(e,n,i){if(n=n.updateQueue,n!==null&&(n=n.shared,(i&4194240)!==0)){var s=n.lanes;s&=e.pendingLanes,i|=s,n.lanes=i,Mr(e,i)}}function mf(e,n){var i=e.updateQueue,s=e.alternate;if(s!==null&&(s=s.updateQueue,i===s)){var a=null,d=null;if(i=i.firstBaseUpdate,i!==null){do{var x={eventTime:i.eventTime,lane:i.lane,tag:i.tag,payload:i.payload,callback:i.callback,next:null};d===null?a=d=x:d=d.next=x,i=i.next}while(i!==null);d===null?a=d=n:d=d.next=n}else a=d=n;i={baseState:s.baseState,firstBaseUpdate:a,lastBaseUpdate:d,shared:s.shared,effects:s.effects},e.updateQueue=i;return}e=i.lastBaseUpdate,e===null?i.firstBaseUpdate=n:e.next=n,i.lastBaseUpdate=n}function us(e,n,i,s){var a=e.updateQueue;Rn=!1;var d=a.firstBaseUpdate,x=a.lastBaseUpdate,T=a.shared.pending;if(T!==null){a.shared.pending=null;var L=T,q=L.next;L.next=null,x===null?d=q:x.next=q,x=L;var se=e.alternate;se!==null&&(se=se.updateQueue,T=se.lastBaseUpdate,T!==x&&(T===null?se.firstBaseUpdate=q:T.next=q,se.lastBaseUpdate=L))}if(d!==null){var ue=a.baseState;x=0,se=q=L=null,T=d;do{var oe=T.lane,fe=T.eventTime;if((s&oe)===oe){se!==null&&(se=se.next={eventTime:fe,lane:0,tag:T.tag,payload:T.payload,callback:T.callback,next:null});e:{var he=e,ge=T;switch(oe=n,fe=i,ge.tag){case 1:if(he=ge.payload,typeof he=="function"){ue=he.call(fe,ue,oe);break e}ue=he;break e;case 3:he.flags=he.flags&-65537|128;case 0:if(he=ge.payload,oe=typeof he=="function"?he.call(fe,ue,oe):he,oe==null)break e;ue=F({},ue,oe);break e;case 2:Rn=!0}}T.callback!==null&&T.lane!==0&&(e.flags|=64,oe=a.effects,oe===null?a.effects=[T]:oe.push(T))}else fe={eventTime:fe,lane:oe,tag:T.tag,payload:T.payload,callback:T.callback,next:null},se===null?(q=se=fe,L=ue):se=se.next=fe,x|=oe;if(T=T.next,T===null){if(T=a.shared.pending,T===null)break;oe=T,T=oe.next,oe.next=null,a.lastBaseUpdate=oe,a.shared.pending=null}}while(!0);if(se===null&&(L=ue),a.baseState=L,a.firstBaseUpdate=q,a.lastBaseUpdate=se,n=a.shared.interleaved,n!==null){a=n;do x|=a.lane,a=a.next;while(a!==n)}else d===null&&(a.shared.lanes=0);sr|=x,e.lanes=x,e.memoizedState=ue}}function yf(e,n,i){if(e=n.effects,n.effects=null,e!==null)for(n=0;ni?i:4,e(!0);var s=Mu.transition;Mu.transition={};try{e(!1),n()}finally{ze=i,Mu.transition=s}}function $f(){return zt().memoizedState}function Um(e,n,i){var s=Dn(e);if(i={lane:s,action:i,hasEagerState:!1,eagerState:null,next:null},Df(e))Of(n,i);else if(i=pf(e,n,i,s),i!==null){var a=lt();Ft(i,e,s,a),Ff(i,n,s)}}function Wm(e,n,i){var s=Dn(e),a={lane:s,action:i,hasEagerState:!1,eagerState:null,next:null};if(Df(e))Of(n,a);else{var d=e.alternate;if(e.lanes===0&&(d===null||d.lanes===0)&&(d=n.lastRenderedReducer,d!==null))try{var x=n.lastRenderedState,T=d(x,i);if(a.hasEagerState=!0,a.eagerState=T,jt(T,x)){var L=n.interleaved;L===null?(a.next=a,Su(n)):(a.next=L.next,L.next=a),n.interleaved=a;return}}catch{}finally{}i=pf(e,n,a,s),i!==null&&(a=lt(),Ft(i,e,s,a),Ff(i,n,s))}}function Df(e){var n=e.alternate;return e===He||n!==null&&n===He}function Of(e,n){Fi=fs=!0;var i=e.pending;i===null?n.next=n:(n.next=i.next,i.next=n),e.pending=n}function Ff(e,n,i){if((i&4194240)!==0){var s=n.lanes;s&=e.pendingLanes,i|=s,n.lanes=i,Mr(e,i)}}var ps={readContext:Tt,useCallback:nt,useContext:nt,useEffect:nt,useImperativeHandle:nt,useInsertionEffect:nt,useLayoutEffect:nt,useMemo:nt,useReducer:nt,useRef:nt,useState:nt,useDebugValue:nt,useDeferredValue:nt,useTransition:nt,useMutableSource:nt,useSyncExternalStore:nt,useId:nt,unstable_isNewReconciler:!1},bm={readContext:Tt,useCallback:function(e,n){return en().memoizedState=[e,n===void 0?null:n],e},useContext:Tt,useEffect:Pf,useImperativeHandle:function(e,n,i){return i=i!=null?i.concat([e]):null,ds(4194308,4,zf.bind(null,n,e),i)},useLayoutEffect:function(e,n){return ds(4194308,4,e,n)},useInsertionEffect:function(e,n){return ds(4,2,e,n)},useMemo:function(e,n){var i=en();return n=n===void 0?null:n,e=e(),i.memoizedState=[e,n],e},useReducer:function(e,n,i){var s=en();return n=i!==void 0?i(n):n,s.memoizedState=s.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},s.queue=e,e=e.dispatch=Um.bind(null,He,e),[s.memoizedState,e]},useRef:function(e){var n=en();return e={current:e},n.memoizedState=e},useState:Cf,useDebugValue:ju,useDeferredValue:function(e){return en().memoizedState=e},useTransition:function(){var e=Cf(!1),n=e[0];return e=Bm.bind(null,e[1]),en().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,i){var s=He,a=en();if(De){if(i===void 0)throw Error(o(407));i=i()}else{if(i=n(),Ge===null)throw Error(o(349));(or&30)!==0||Sf(s,n,i)}a.memoizedState=i;var d={value:i,getSnapshot:n};return a.queue=d,Pf(Ef.bind(null,s,d,e),[e]),s.flags|=2048,Bi(9,_f.bind(null,s,d,i,n),void 0,null),i},useId:function(){var e=en(),n=Ge.identifierPrefix;if(De){var i=dn,s=fn;i=(s&~(1<<32-xt(s)-1)).toString(32)+i,n=":"+n+"R"+i,i=Hi++,0<\/script>",e=e.removeChild(e.firstChild)):typeof s.is=="string"?e=x.createElement(i,{is:s.is}):(e=x.createElement(i),i==="select"&&(x=e,s.multiple?x.multiple=!0:s.size&&(x.size=s.size))):e=x.createElementNS(e,i),e[Zt]=n,e[Li]=s,od(e,n,!1,!1),n.stateNode=e;e:{switch(x=li(i,s),i){case"dialog":je("cancel",e),je("close",e),a=s;break;case"iframe":case"object":case"embed":je("load",e),a=s;break;case"video":case"audio":for(a=0;aQr&&(n.flags|=128,s=!0,Ui(d,!1),n.lanes=4194304)}else{if(!s)if(e=as(x),e!==null){if(n.flags|=128,s=!0,i=e.updateQueue,i!==null&&(n.updateQueue=i,n.flags|=4),Ui(d,!0),d.tail===null&&d.tailMode==="hidden"&&!x.alternate&&!De)return rt(n),null}else 2*Oe()-d.renderingStartTime>Qr&&i!==1073741824&&(n.flags|=128,s=!0,Ui(d,!1),n.lanes=4194304);d.isBackwards?(x.sibling=n.child,n.child=x):(i=d.last,i!==null?i.sibling=x:n.child=x,d.last=x)}return d.tail!==null?(n=d.tail,d.rendering=n,d.tail=n.sibling,d.renderingStartTime=Oe(),n.sibling=null,i=Fe.current,Le(Fe,s?i&1|2:i&1),n):(rt(n),null);case 22:case 23:return ia(),s=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==s&&(n.flags|=8192),s&&(n.mode&1)!==0?(Et&1073741824)!==0&&(rt(n),n.subtreeFlags&6&&(n.flags|=8192)):rt(n),null;case 24:return null;case 25:return null}throw Error(o(156,n.tag))}function Jm(e,n){switch(hu(n),n.tag){case 1:return ft(n.type)&&Zo(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return Wr(),Ae(ct),Ae(tt),Cu(),e=n.flags,(e&65536)!==0&&(e&128)===0?(n.flags=e&-65537|128,n):null;case 5:return ku(n),null;case 13:if(Ae(Fe),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(o(340));Hr()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return Ae(Fe),null;case 4:return Wr(),null;case 10:return xu(n.type._context),null;case 22:case 23:return ia(),null;case 24:return null;default:return null}}var vs=!1,it=!1,e0=typeof WeakSet=="function"?WeakSet:Set,de=null;function Yr(e,n){var i=e.ref;if(i!==null)if(typeof i=="function")try{i(null)}catch(s){Be(e,n,s)}else i.current=null}function Yu(e,n,i){try{i()}catch(s){Be(e,n,s)}}var ud=!1;function t0(e,n){if(iu=Oo,e=Fc(),Kl(e)){if("selectionStart"in e)var i={start:e.selectionStart,end:e.selectionEnd};else e:{i=(i=e.ownerDocument)&&i.defaultView||window;var s=i.getSelection&&i.getSelection();if(s&&s.rangeCount!==0){i=s.anchorNode;var a=s.anchorOffset,d=s.focusNode;s=s.focusOffset;try{i.nodeType,d.nodeType}catch{i=null;break e}var x=0,T=-1,L=-1,q=0,se=0,ue=e,oe=null;t:for(;;){for(var fe;ue!==i||a!==0&&ue.nodeType!==3||(T=x+a),ue!==d||s!==0&&ue.nodeType!==3||(L=x+s),ue.nodeType===3&&(x+=ue.nodeValue.length),(fe=ue.firstChild)!==null;)oe=ue,ue=fe;for(;;){if(ue===e)break t;if(oe===i&&++q===a&&(T=x),oe===d&&++se===s&&(L=x),(fe=ue.nextSibling)!==null)break;ue=oe,oe=ue.parentNode}ue=fe}i=T===-1||L===-1?null:{start:T,end:L}}else i=null}i=i||{start:0,end:0}}else i=null;for(ou={focusedElem:e,selectionRange:i},Oo=!1,de=n;de!==null;)if(n=de,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,de=e;else for(;de!==null;){n=de;try{var he=n.alternate;if((n.flags&1024)!==0)switch(n.tag){case 0:case 11:case 15:break;case 1:if(he!==null){var ge=he.memoizedProps,Ue=he.memoizedState,b=n.stateNode,D=b.getSnapshotBeforeUpdate(n.elementType===n.type?ge:$t(n.type,ge),Ue);b.__reactInternalSnapshotBeforeUpdate=D}break;case 3:var Y=n.stateNode.containerInfo;Y.nodeType===1?Y.textContent="":Y.nodeType===9&&Y.documentElement&&Y.removeChild(Y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(o(163))}}catch(ae){Be(n,n.return,ae)}if(e=n.sibling,e!==null){e.return=n.return,de=e;break}de=n.return}return he=ud,ud=!1,he}function Wi(e,n,i){var s=n.updateQueue;if(s=s!==null?s.lastEffect:null,s!==null){var a=s=s.next;do{if((a.tag&e)===e){var d=a.destroy;a.destroy=void 0,d!==void 0&&Yu(n,i,d)}a=a.next}while(a!==s)}}function xs(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var i=n=n.next;do{if((i.tag&e)===e){var s=i.create;i.destroy=s()}i=i.next}while(i!==n)}}function Xu(e){var n=e.ref;if(n!==null){var i=e.stateNode;switch(e.tag){case 5:e=i;break;default:e=i}typeof n=="function"?n(e):n.current=e}}function ad(e){var n=e.alternate;n!==null&&(e.alternate=null,ad(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[Zt],delete n[Li],delete n[au],delete n[Dm],delete n[Om])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function cd(e){return e.tag===5||e.tag===3||e.tag===4}function fd(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||cd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Qu(e,n,i){var s=e.tag;if(s===5||s===6)e=e.stateNode,n?i.nodeType===8?i.parentNode.insertBefore(e,n):i.insertBefore(e,n):(i.nodeType===8?(n=i.parentNode,n.insertBefore(e,i)):(n=i,n.appendChild(e)),i=i._reactRootContainer,i!=null||n.onclick!==null||(n.onclick=Ko));else if(s!==4&&(e=e.child,e!==null))for(Qu(e,n,i),e=e.sibling;e!==null;)Qu(e,n,i),e=e.sibling}function Gu(e,n,i){var s=e.tag;if(s===5||s===6)e=e.stateNode,n?i.insertBefore(e,n):i.appendChild(e);else if(s!==4&&(e=e.child,e!==null))for(Gu(e,n,i),e=e.sibling;e!==null;)Gu(e,n,i),e=e.sibling}var qe=null,Dt=!1;function jn(e,n,i){for(i=i.child;i!==null;)dd(e,n,i),i=i.sibling}function dd(e,n,i){if(vt&&typeof vt.onCommitFiberUnmount=="function")try{vt.onCommitFiberUnmount(kr,i)}catch{}switch(i.tag){case 5:it||Yr(i,n);case 6:var s=qe,a=Dt;qe=null,jn(e,n,i),qe=s,Dt=a,qe!==null&&(Dt?(e=qe,i=i.stateNode,e.nodeType===8?e.parentNode.removeChild(i):e.removeChild(i)):qe.removeChild(i.stateNode));break;case 18:qe!==null&&(Dt?(e=qe,i=i.stateNode,e.nodeType===8?uu(e.parentNode,i):e.nodeType===1&&uu(e,i),_i(e)):uu(qe,i.stateNode));break;case 4:s=qe,a=Dt,qe=i.stateNode.containerInfo,Dt=!0,jn(e,n,i),qe=s,Dt=a;break;case 0:case 11:case 14:case 15:if(!it&&(s=i.updateQueue,s!==null&&(s=s.lastEffect,s!==null))){a=s=s.next;do{var d=a,x=d.destroy;d=d.tag,x!==void 0&&((d&2)!==0||(d&4)!==0)&&Yu(i,n,x),a=a.next}while(a!==s)}jn(e,n,i);break;case 1:if(!it&&(Yr(i,n),s=i.stateNode,typeof s.componentWillUnmount=="function"))try{s.props=i.memoizedProps,s.state=i.memoizedState,s.componentWillUnmount()}catch(T){Be(i,n,T)}jn(e,n,i);break;case 21:jn(e,n,i);break;case 22:i.mode&1?(it=(s=it)||i.memoizedState!==null,jn(e,n,i),it=s):jn(e,n,i);break;default:jn(e,n,i)}}function hd(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var i=e.stateNode;i===null&&(i=e.stateNode=new e0),n.forEach(function(s){var a=c0.bind(null,e,s);i.has(s)||(i.add(s),s.then(a,a))})}}function Ot(e,n){var i=n.deletions;if(i!==null)for(var s=0;sa&&(a=x),s&=~d}if(s=a,s=Oe()-s,s=(120>s?120:480>s?480:1080>s?1080:1920>s?1920:3e3>s?3e3:4320>s?4320:1960*r0(s/1960))-s,10e?16:e,$n===null)var s=!1;else{if(e=$n,$n=null,ks=0,(Pe&6)!==0)throw Error(o(331));var a=Pe;for(Pe|=4,de=e.current;de!==null;){var d=de,x=d.child;if((de.flags&16)!==0){var T=d.deletions;if(T!==null){for(var L=0;LOe()-Zu?ur(e,0):qu|=i),pt(e,n)}function Cd(e,n){n===0&&((e.mode&1)===0?n=1:(n=Cr,Cr<<=1,(Cr&130023424)===0&&(Cr=4194304)));var i=lt();e=hn(e,n),e!==null&&(Zn(e,n,i),pt(e,i))}function a0(e){var n=e.memoizedState,i=0;n!==null&&(i=n.retryLane),Cd(e,i)}function c0(e,n){var i=0;switch(e.tag){case 13:var s=e.stateNode,a=e.memoizedState;a!==null&&(i=a.retryLane);break;case 19:s=e.stateNode;break;default:throw Error(o(314))}s!==null&&s.delete(n),Cd(e,i)}var Md;Md=function(e,n,i){if(e!==null)if(e.memoizedProps!==n.pendingProps||ct.current)dt=!0;else{if((e.lanes&i)===0&&(n.flags&128)===0)return dt=!1,qm(e,n,i);dt=(e.flags&131072)!==0}else dt=!1,De&&(n.flags&1048576)!==0&&sf(n,ns,n.index);switch(n.lanes=0,n.tag){case 2:var s=n.type;ys(e,n),e=n.pendingProps;var a=Dr(n,tt.current);Ur(n,i),a=Iu(null,n,s,e,a,i);var d=Tu();return n.flags|=1,typeof a=="object"&&a!==null&&typeof a.render=="function"&&a.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,ft(s)?(d=!0,Jo(n)):d=!1,n.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,_u(n),a.updater=gs,n.stateNode=a,a._reactInternals=n,$u(n,s,e,i),n=Hu(null,n,s,!0,d,i)):(n.tag=0,De&&d&&du(n),st(null,n,a,i),n=n.child),n;case 16:s=n.elementType;e:{switch(ys(e,n),e=n.pendingProps,a=s._init,s=a(s._payload),n.type=s,a=n.tag=d0(s),e=$t(s,e),a){case 0:n=Fu(null,n,s,e,i);break e;case 1:n=Jf(null,n,s,e,i);break e;case 11:n=Qf(null,n,s,e,i);break e;case 14:n=Gf(null,n,s,$t(s.type,e),i);break e}throw Error(o(306,s,""))}return n;case 0:return s=n.type,a=n.pendingProps,a=n.elementType===s?a:$t(s,a),Fu(e,n,s,a,i);case 1:return s=n.type,a=n.pendingProps,a=n.elementType===s?a:$t(s,a),Jf(e,n,s,a,i);case 3:e:{if(ed(n),e===null)throw Error(o(387));s=n.pendingProps,d=n.memoizedState,a=d.element,gf(e,n),us(n,s,null,i);var x=n.memoizedState;if(s=x.element,d.isDehydrated)if(d={element:s,isDehydrated:!1,cache:x.cache,pendingSuspenseBoundaries:x.pendingSuspenseBoundaries,transitions:x.transitions},n.updateQueue.baseState=d,n.memoizedState=d,n.flags&256){a=br(Error(o(423)),n),n=td(e,n,s,i,a);break e}else if(s!==a){a=br(Error(o(424)),n),n=td(e,n,s,i,a);break e}else for(_t=Pn(n.stateNode.containerInfo.firstChild),St=n,De=!0,At=null,i=hf(n,null,s,i),n.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling;else{if(Hr(),s===a){n=gn(e,n,i);break e}st(e,n,s,i)}n=n.child}return n;case 5:return vf(n),e===null&&gu(n),s=n.type,a=n.pendingProps,d=e!==null?e.memoizedProps:null,x=a.children,su(s,a)?x=null:d!==null&&su(s,d)&&(n.flags|=32),Zf(e,n),st(e,n,x,i),n.child;case 6:return e===null&&gu(n),null;case 13:return nd(e,n,i);case 4:return Eu(n,n.stateNode.containerInfo),s=n.pendingProps,e===null?n.child=Vr(n,null,s,i):st(e,n,s,i),n.child;case 11:return s=n.type,a=n.pendingProps,a=n.elementType===s?a:$t(s,a),Qf(e,n,s,a,i);case 7:return st(e,n,n.pendingProps,i),n.child;case 8:return st(e,n,n.pendingProps.children,i),n.child;case 12:return st(e,n,n.pendingProps.children,i),n.child;case 10:e:{if(s=n.type._context,a=n.pendingProps,d=n.memoizedProps,x=a.value,Le(os,s._currentValue),s._currentValue=x,d!==null)if(jt(d.value,x)){if(d.children===a.children&&!ct.current){n=gn(e,n,i);break e}}else for(d=n.child,d!==null&&(d.return=n);d!==null;){var T=d.dependencies;if(T!==null){x=d.child;for(var L=T.firstContext;L!==null;){if(L.context===s){if(d.tag===1){L=pn(-1,i&-i),L.tag=2;var q=d.updateQueue;if(q!==null){q=q.shared;var se=q.pending;se===null?L.next=L:(L.next=se.next,se.next=L),q.pending=L}}d.lanes|=i,L=d.alternate,L!==null&&(L.lanes|=i),wu(d.return,i,n),T.lanes|=i;break}L=L.next}}else if(d.tag===10)x=d.type===n.type?null:d.child;else if(d.tag===18){if(x=d.return,x===null)throw Error(o(341));x.lanes|=i,T=x.alternate,T!==null&&(T.lanes|=i),wu(x,i,n),x=d.sibling}else x=d.child;if(x!==null)x.return=d;else for(x=d;x!==null;){if(x===n){x=null;break}if(d=x.sibling,d!==null){d.return=x.return,x=d;break}x=x.return}d=x}st(e,n,a.children,i),n=n.child}return n;case 9:return a=n.type,s=n.pendingProps.children,Ur(n,i),a=Tt(a),s=s(a),n.flags|=1,st(e,n,s,i),n.child;case 14:return s=n.type,a=$t(s,n.pendingProps),a=$t(s.type,a),Gf(e,n,s,a,i);case 15:return Kf(e,n,n.type,n.pendingProps,i);case 17:return s=n.type,a=n.pendingProps,a=n.elementType===s?a:$t(s,a),ys(e,n),n.tag=1,ft(s)?(e=!0,Jo(n)):e=!1,Ur(n,i),Vf(n,s,a),$u(n,s,a,i),Hu(null,n,s,!0,e,i);case 19:return id(e,n,i);case 22:return qf(e,n,i)}throw Error(o(156,n.tag))};function Pd(e,n){return Io(e,n)}function f0(e,n,i,s){this.tag=e,this.key=i,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=s,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Lt(e,n,i,s){return new f0(e,n,i,s)}function sa(e){return e=e.prototype,!(!e||!e.isReactComponent)}function d0(e){if(typeof e=="function")return sa(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ee)return 11;if(e===B)return 14}return 2}function Fn(e,n){var i=e.alternate;return i===null?(i=Lt(e.tag,n,e.key,e.mode),i.elementType=e.elementType,i.type=e.type,i.stateNode=e.stateNode,i.alternate=e,e.alternate=i):(i.pendingProps=n,i.type=e.type,i.flags=0,i.subtreeFlags=0,i.deletions=null),i.flags=e.flags&14680064,i.childLanes=e.childLanes,i.lanes=e.lanes,i.child=e.child,i.memoizedProps=e.memoizedProps,i.memoizedState=e.memoizedState,i.updateQueue=e.updateQueue,n=e.dependencies,i.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},i.sibling=e.sibling,i.index=e.index,i.ref=e.ref,i}function Ps(e,n,i,s,a,d){var x=2;if(s=e,typeof e=="function")sa(e)&&(x=1);else if(typeof e=="string")x=5;else e:switch(e){case V:return cr(i.children,a,d,n);case X:x=8,a|=8;break;case K:return e=Lt(12,i,n,a|2),e.elementType=K,e.lanes=d,e;case J:return e=Lt(13,i,n,a),e.elementType=J,e.lanes=d,e;case k:return e=Lt(19,i,n,a),e.elementType=k,e.lanes=d,e;case U:return Is(i,a,d,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case te:x=10;break e;case Z:x=9;break e;case ee:x=11;break e;case B:x=14;break e;case H:x=16,s=null;break e}throw Error(o(130,e==null?e:typeof e,""))}return n=Lt(x,i,n,a),n.elementType=e,n.type=s,n.lanes=d,n}function cr(e,n,i,s){return e=Lt(7,e,s,n),e.lanes=i,e}function Is(e,n,i,s){return e=Lt(22,e,s,n),e.elementType=U,e.lanes=i,e.stateNode={isHidden:!1},e}function la(e,n,i){return e=Lt(6,e,null,n),e.lanes=i,e}function ua(e,n,i){return n=Lt(4,e.children!==null?e.children:[],e.key,n),n.lanes=i,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function h0(e,n,i,s,a){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=qn(0),this.expirationTimes=qn(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=qn(0),this.identifierPrefix=s,this.onRecoverableError=a,this.mutableSourceEagerHydrationData=null}function aa(e,n,i,s,a,d,x,T,L){return e=new h0(e,n,i,T,L),n===1?(n=1,d===!0&&(n|=8)):n=0,d=Lt(3,null,null,n),e.current=d,d.stateNode=e,d.memoizedState={element:s,isDehydrated:i,cache:null,transitions:null,pendingSuspenseBoundaries:null},_u(d),e}function p0(e,n,i){var s=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(r){console.error(r)}}return t(),ga.exports=C0(),ga.exports}var Bd;function M0(){if(Bd)return $s;Bd=1;var t=Jh();return $s.createRoot=t.createRoot,$s.hydrateRoot=t.hydrateRoot,$s}var P0=M0();async function Ht(t,r){const o=await fetch(t,{...r,headers:{"Content-Type":"application/json",...(r==null?void 0:r.headers)||{}}});if(!o.ok){const l=await o.text();throw new Error(l||o.statusText)}return o.json()}const Vt={health:()=>Ht("/api/health"),settings:()=>Ht("/api/settings"),saveSettings:t=>Ht("/api/settings",{method:"PUT",body:JSON.stringify(t)}),repos:()=>Ht("/api/repos"),index:(t,r=!0)=>Ht("/api/index",{method:"POST",body:JSON.stringify({repo_path:t,incremental:r})}),indexStatus:t=>Ht(`/api/index?repo_path=${encodeURIComponent(t)}`),architecture:t=>Ht(`/api/architecture?repo_path=${encodeURIComponent(t)}`),review:(t,r,o,l=!0)=>Ht("/api/review",{method:"POST",body:JSON.stringify({repo_path:t,base:r,head:o||null,reindex:l,incremental:!0})}),graph:(t,r="full")=>Ht(`/api/graph?repo_path=${encodeURIComponent(t)}&scope=${r}`),prs:(t,r,o="open")=>Ht("/api/prs",{method:"POST",body:JSON.stringify({provider:t,repo:r,state:o})}),residual:t=>Ht("/api/ai/residual",{method:"POST",body:JSON.stringify({review:t})})};function be(t){if(typeof t=="string"||typeof t=="number")return""+t;let r="";if(Array.isArray(t))for(let o=0,l;o{}};function al(){for(var t=0,r=arguments.length,o={},l;t=0&&(l=o.slice(u+1),o=o.slice(0,u)),o&&!r.hasOwnProperty(o))throw new Error("unknown type: "+o);return{type:o,name:l}})}Ys.prototype=al.prototype={constructor:Ys,on:function(t,r){var o=this._,l=T0(t+"",o),u,c=-1,f=l.length;if(arguments.length<2){for(;++c0)for(var o=new Array(u),l=0,u,c;l=0&&(r=t.slice(0,o))!=="xmlns"&&(t=t.slice(o+1)),Wd.hasOwnProperty(r)?{space:Wd[r],local:t}:t}function R0(t){return function(){var r=this.ownerDocument,o=this.namespaceURI;return o===Ra&&r.documentElement.namespaceURI===Ra?r.createElement(t):r.createElementNS(o,t)}}function L0(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function ep(t){var r=cl(t);return(r.local?L0:R0)(r)}function j0(){}function Ya(t){return t==null?j0:function(){return this.querySelector(t)}}function A0(t){typeof t!="function"&&(t=Ya(t));for(var r=this._groups,o=r.length,l=new Array(o),u=0;u=_&&(_=R+1);!(W=N[_])&&++_=0;)(f=l[u])&&(c&&f.compareDocumentPosition(c)^4&&c.parentNode.insertBefore(f,c),c=f);return this}function sy(t){t||(t=ly);function r(v,g){return v&&g?t(v.__data__,g.__data__):!v-!g}for(var o=this._groups,l=o.length,u=new Array(l),c=0;cr?1:t>=r?0:NaN}function uy(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function ay(){return Array.from(this)}function cy(){for(var t=this._groups,r=0,o=t.length;r1?this.each((r==null?Sy:typeof r=="function"?Ey:_y)(t,r,o??"")):ei(this.node(),t)}function ei(t,r){return t.style.getPropertyValue(r)||op(t).getComputedStyle(t,null).getPropertyValue(r)}function Ny(t){return function(){delete this[t]}}function Cy(t,r){return function(){this[t]=r}}function My(t,r){return function(){var o=r.apply(this,arguments);o==null?delete this[t]:this[t]=o}}function Py(t,r){return arguments.length>1?this.each((r==null?Ny:typeof r=="function"?My:Cy)(t,r)):this.node()[t]}function sp(t){return t.trim().split(/^|\s+/)}function Xa(t){return t.classList||new lp(t)}function lp(t){this._node=t,this._names=sp(t.getAttribute("class")||"")}lp.prototype={add:function(t){var r=this._names.indexOf(t);r<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var r=this._names.indexOf(t);r>=0&&(this._names.splice(r,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function up(t,r){for(var o=Xa(t),l=-1,u=r.length;++l=0&&(o=r.slice(l+1),r=r.slice(0,l)),{type:r,name:o}})}function nv(t){return function(){var r=this.__on;if(r){for(var o=0,l=-1,u=r.length,c;o()=>t;function La(t,{sourceEvent:r,subject:o,target:l,identifier:u,active:c,x:f,y:h,dx:p,dy:y,dispatch:m}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:r,enumerable:!0,configurable:!0},subject:{value:o,enumerable:!0,configurable:!0},target:{value:l,enumerable:!0,configurable:!0},identifier:{value:u,enumerable:!0,configurable:!0},active:{value:c,enumerable:!0,configurable:!0},x:{value:f,enumerable:!0,configurable:!0},y:{value:h,enumerable:!0,configurable:!0},dx:{value:p,enumerable:!0,configurable:!0},dy:{value:y,enumerable:!0,configurable:!0},_:{value:m}})}La.prototype.on=function(){var t=this._.on.apply(this._,arguments);return t===this._?this:t};function dv(t){return!t.ctrlKey&&!t.button}function hv(){return this.parentNode}function pv(t,r){return r??{x:t.x,y:t.y}}function gv(){return navigator.maxTouchPoints||"ontouchstart"in this}function pp(){var t=dv,r=hv,o=pv,l=gv,u={},c=al("start","drag","end"),f=0,h,p,y,m,v=0;function g(I){I.on("mousedown.drag",w).filter(l).on("touchstart.drag",N).on("touchmove.drag",E,fv).on("touchend.drag touchcancel.drag",R).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function w(I,W){if(!(m||!t.call(this,I,W))){var $=_(this,r.call(this,I,W),I,W,"mouse");$&&(kt(I.view).on("mousemove.drag",S,no).on("mouseup.drag",M,no),dp(I.view),va(I),y=!1,h=I.clientX,p=I.clientY,$("start",I))}}function S(I){if(Zr(I),!y){var W=I.clientX-h,$=I.clientY-p;y=W*W+$*$>v}u.mouse("drag",I)}function M(I){kt(I.view).on("mousemove.drag mouseup.drag",null),hp(I.view,y),Zr(I),u.mouse("end",I)}function N(I,W){if(t.call(this,I,W)){var $=I.changedTouches,V=r.call(this,I,W),X=$.length,K,te;for(K=0;K>8&15|r>>4&240,r>>4&15|r&240,(r&15)<<4|r&15,1):o===8?Os(r>>24&255,r>>16&255,r>>8&255,(r&255)/255):o===4?Os(r>>12&15|r>>8&240,r>>8&15|r>>4&240,r>>4&15|r&240,((r&15)<<4|r&15)/255):null):(r=yv.exec(t))?new mt(r[1],r[2],r[3],1):(r=vv.exec(t))?new mt(r[1]*255/100,r[2]*255/100,r[3]*255/100,1):(r=xv.exec(t))?Os(r[1],r[2],r[3],r[4]):(r=wv.exec(t))?Os(r[1]*255/100,r[2]*255/100,r[3]*255/100,r[4]):(r=Sv.exec(t))?qd(r[1],r[2]/100,r[3]/100,1):(r=_v.exec(t))?qd(r[1],r[2]/100,r[3]/100,r[4]):bd.hasOwnProperty(t)?Qd(bd[t]):t==="transparent"?new mt(NaN,NaN,NaN,0):null}function Qd(t){return new mt(t>>16&255,t>>8&255,t&255,1)}function Os(t,r,o,l){return l<=0&&(t=r=o=NaN),new mt(t,r,o,l)}function Nv(t){return t instanceof po||(t=gr(t)),t?(t=t.rgb(),new mt(t.r,t.g,t.b,t.opacity)):new mt}function ja(t,r,o,l){return arguments.length===1?Nv(t):new mt(t,r,o,l??1)}function mt(t,r,o,l){this.r=+t,this.g=+r,this.b=+o,this.opacity=+l}Qa(mt,ja,gp(po,{brighter(t){return t=t==null?Zs:Math.pow(Zs,t),new mt(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?ro:Math.pow(ro,t),new mt(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new mt(hr(this.r),hr(this.g),hr(this.b),Js(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Gd,formatHex:Gd,formatHex8:Cv,formatRgb:Kd,toString:Kd}));function Gd(){return`#${dr(this.r)}${dr(this.g)}${dr(this.b)}`}function Cv(){return`#${dr(this.r)}${dr(this.g)}${dr(this.b)}${dr((isNaN(this.opacity)?1:this.opacity)*255)}`}function Kd(){const t=Js(this.opacity);return`${t===1?"rgb(":"rgba("}${hr(this.r)}, ${hr(this.g)}, ${hr(this.b)}${t===1?")":`, ${t})`}`}function Js(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function hr(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function dr(t){return t=hr(t),(t<16?"0":"")+t.toString(16)}function qd(t,r,o,l){return l<=0?t=r=o=NaN:o<=0||o>=1?t=r=NaN:r<=0&&(t=NaN),new Ut(t,r,o,l)}function mp(t){if(t instanceof Ut)return new Ut(t.h,t.s,t.l,t.opacity);if(t instanceof po||(t=gr(t)),!t)return new Ut;if(t instanceof Ut)return t;t=t.rgb();var r=t.r/255,o=t.g/255,l=t.b/255,u=Math.min(r,o,l),c=Math.max(r,o,l),f=NaN,h=c-u,p=(c+u)/2;return h?(r===c?f=(o-l)/h+(o0&&p<1?0:f,new Ut(f,h,p,t.opacity)}function Mv(t,r,o,l){return arguments.length===1?mp(t):new Ut(t,r,o,l??1)}function Ut(t,r,o,l){this.h=+t,this.s=+r,this.l=+o,this.opacity=+l}Qa(Ut,Mv,gp(po,{brighter(t){return t=t==null?Zs:Math.pow(Zs,t),new Ut(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?ro:Math.pow(ro,t),new Ut(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,r=isNaN(t)||isNaN(this.s)?0:this.s,o=this.l,l=o+(o<.5?o:1-o)*r,u=2*o-l;return new mt(xa(t>=240?t-240:t+120,u,l),xa(t,u,l),xa(t<120?t+240:t-120,u,l),this.opacity)},clamp(){return new Ut(Zd(this.h),Fs(this.s),Fs(this.l),Js(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Js(this.opacity);return`${t===1?"hsl(":"hsla("}${Zd(this.h)}, ${Fs(this.s)*100}%, ${Fs(this.l)*100}%${t===1?")":`, ${t})`}`}}));function Zd(t){return t=(t||0)%360,t<0?t+360:t}function Fs(t){return Math.max(0,Math.min(1,t||0))}function xa(t,r,o){return(t<60?r+(o-r)*t/60:t<180?o:t<240?r+(o-r)*(240-t)/60:r)*255}const Ga=t=>()=>t;function Pv(t,r){return function(o){return t+o*r}}function Iv(t,r,o){return t=Math.pow(t,o),r=Math.pow(r,o)-t,o=1/o,function(l){return Math.pow(t+l*r,o)}}function Tv(t){return(t=+t)==1?yp:function(r,o){return o-r?Iv(r,o,t):Ga(isNaN(r)?o:r)}}function yp(t,r){var o=r-t;return o?Pv(t,o):Ga(isNaN(t)?r:t)}const el=(function t(r){var o=Tv(r);function l(u,c){var f=o((u=ja(u)).r,(c=ja(c)).r),h=o(u.g,c.g),p=o(u.b,c.b),y=yp(u.opacity,c.opacity);return function(m){return u.r=f(m),u.g=h(m),u.b=p(m),u.opacity=y(m),u+""}}return l.gamma=t,l})(1);function zv(t,r){r||(r=[]);var o=t?Math.min(r.length,t.length):0,l=r.slice(),u;return function(c){for(u=0;uo&&(c=r.slice(o,c),h[f]?h[f]+=c:h[++f]=c),(l=l[0])===(u=u[0])?h[f]?h[f]+=u:h[++f]=u:(h[++f]=null,p.push({i:f,x:nn(l,u)})),o=wa.lastIndex;return o180?m+=360:m-y>180&&(y+=360),g.push({i:v.push(u(v)+"rotate(",null,l)-2,x:nn(y,m)})):m&&v.push(u(v)+"rotate("+m+l)}function h(y,m,v,g){y!==m?g.push({i:v.push(u(v)+"skewX(",null,l)-2,x:nn(y,m)}):m&&v.push(u(v)+"skewX("+m+l)}function p(y,m,v,g,w,S){if(y!==v||m!==g){var M=w.push(u(w)+"scale(",null,",",null,")");S.push({i:M-4,x:nn(y,v)},{i:M-2,x:nn(m,g)})}else(v!==1||g!==1)&&w.push(u(w)+"scale("+v+","+g+")")}return function(y,m){var v=[],g=[];return y=t(y),m=t(m),c(y.translateX,y.translateY,m.translateX,m.translateY,v,g),f(y.rotate,m.rotate,v,g),h(y.skewX,m.skewX,v,g),p(y.scaleX,y.scaleY,m.scaleX,m.scaleY,v,g),y=m=null,function(w){for(var S=-1,M=g.length,N;++S=0&&t._call.call(void 0,r),t=t._next;--ti}function th(){mr=(nl=oo.now())+fl,ti=Zi=0;try{Yv()}finally{ti=0,Qv(),mr=0}}function Xv(){var t=oo.now(),r=t-nl;r>Sp&&(fl-=r,nl=t)}function Qv(){for(var t,r=tl,o,l=1/0;r;)r._call?(l>r._time&&(l=r._time),t=r,r=r._next):(o=r._next,r._next=null,r=t?t._next=o:tl=o);Ji=t,Da(l)}function Da(t){if(!ti){Zi&&(Zi=clearTimeout(Zi));var r=t-mr;r>24?(t<1/0&&(Zi=setTimeout(th,t-oo.now()-fl)),Ki&&(Ki=clearInterval(Ki))):(Ki||(nl=oo.now(),Ki=setInterval(Xv,Sp)),ti=1,_p(th))}}function nh(t,r,o){var l=new rl;return r=r==null?0:+r,l.restart(u=>{l.stop(),t(u+r)},r,o),l}var Gv=al("start","end","cancel","interrupt"),Kv=[],kp=0,rh=1,Oa=2,Qs=3,ih=4,Fa=5,Gs=6;function dl(t,r,o,l,u,c){var f=t.__transition;if(!f)t.__transition={};else if(o in f)return;qv(t,o,{name:r,index:l,group:u,on:Gv,tween:Kv,time:c.time,delay:c.delay,duration:c.duration,ease:c.ease,timer:null,state:kp})}function qa(t,r){var o=Xt(t,r);if(o.state>kp)throw new Error("too late; already scheduled");return o}function on(t,r){var o=Xt(t,r);if(o.state>Qs)throw new Error("too late; already running");return o}function Xt(t,r){var o=t.__transition;if(!o||!(o=o[r]))throw new Error("transition not found");return o}function qv(t,r,o){var l=t.__transition,u;l[r]=o,o.timer=Ep(c,0,o.time);function c(y){o.state=rh,o.timer.restart(f,o.delay,o.time),o.delay<=y&&f(y-o.delay)}function f(y){var m,v,g,w;if(o.state!==rh)return p();for(m in l)if(w=l[m],w.name===o.name){if(w.state===Qs)return nh(f);w.state===ih?(w.state=Gs,w.timer.stop(),w.on.call("interrupt",t,t.__data__,w.index,w.group),delete l[m]):+mOa&&l.state=0&&(r=r.slice(0,o)),!r||r==="start"})}function Mx(t,r,o){var l,u,c=Cx(r)?qa:on;return function(){var f=c(this,t),h=f.on;h!==l&&(u=(l=h).copy()).on(r,o),f.on=u}}function Px(t,r){var o=this._id;return arguments.length<2?Xt(this.node(),o).on.on(t):this.each(Mx(o,t,r))}function Ix(t){return function(){var r=this.parentNode;for(var o in this.__transition)if(+o!==t)return;r&&r.removeChild(this)}}function Tx(){return this.on("end.remove",Ix(this._id))}function zx(t){var r=this._name,o=this._id;typeof t!="function"&&(t=Ya(t));for(var l=this._groups,u=l.length,c=new Array(u),f=0;f()=>t;function nw(t,{sourceEvent:r,target:o,transform:l,dispatch:u}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:r,enumerable:!0,configurable:!0},target:{value:o,enumerable:!0,configurable:!0},transform:{value:l,enumerable:!0,configurable:!0},_:{value:u}})}function vn(t,r,o){this.k=t,this.x=r,this.y=o}vn.prototype={constructor:vn,scale:function(t){return t===1?this:new vn(this.k*t,this.x,this.y)},translate:function(t,r){return t===0&r===0?this:new vn(this.k,this.x+this.k*t,this.y+this.k*r)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var hl=new vn(1,0,0);Pp.prototype=vn.prototype;function Pp(t){for(;!t.__zoom;)if(!(t=t.parentNode))return hl;return t.__zoom}function Sa(t){t.stopImmediatePropagation()}function qi(t){t.preventDefault(),t.stopImmediatePropagation()}function rw(t){return(!t.ctrlKey||t.type==="wheel")&&!t.button}function iw(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t,t.hasAttribute("viewBox")?(t=t.viewBox.baseVal,[[t.x,t.y],[t.x+t.width,t.y+t.height]]):[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]):[[0,0],[t.clientWidth,t.clientHeight]]}function oh(){return this.__zoom||hl}function ow(t){return-t.deltaY*(t.deltaMode===1?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function sw(){return navigator.maxTouchPoints||"ontouchstart"in this}function lw(t,r,o){var l=t.invertX(r[0][0])-o[0][0],u=t.invertX(r[1][0])-o[1][0],c=t.invertY(r[0][1])-o[0][1],f=t.invertY(r[1][1])-o[1][1];return t.translate(u>l?(l+u)/2:Math.min(0,l)||Math.max(0,u),f>c?(c+f)/2:Math.min(0,c)||Math.max(0,f))}function Ip(){var t=rw,r=iw,o=lw,l=ow,u=sw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],h=250,p=Xs,y=al("start","zoom","end"),m,v,g,w=500,S=150,M=0,N=10;function E(k){k.property("__zoom",oh).on("wheel.zoom",X,{passive:!1}).on("mousedown.zoom",K).on("dblclick.zoom",te).filter(u).on("touchstart.zoom",Z).on("touchmove.zoom",ee).on("touchend.zoom touchcancel.zoom",J).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}E.transform=function(k,B,H,U){var A=k.selection?k.selection():k;A.property("__zoom",oh),k!==A?W(k,B,H,U):A.interrupt().each(function(){$(this,arguments).event(U).start().zoom(null,typeof B=="function"?B.apply(this,arguments):B).end()})},E.scaleBy=function(k,B,H,U){E.scaleTo(k,function(){var A=this.__zoom.k,j=typeof B=="function"?B.apply(this,arguments):B;return A*j},H,U)},E.scaleTo=function(k,B,H,U){E.transform(k,function(){var A=r.apply(this,arguments),j=this.__zoom,F=H==null?I(A):typeof H=="function"?H.apply(this,arguments):H,C=j.invert(F),z=typeof B=="function"?B.apply(this,arguments):B;return o(_(R(j,z),F,C),A,f)},H,U)},E.translateBy=function(k,B,H,U){E.transform(k,function(){return o(this.__zoom.translate(typeof B=="function"?B.apply(this,arguments):B,typeof H=="function"?H.apply(this,arguments):H),r.apply(this,arguments),f)},null,U)},E.translateTo=function(k,B,H,U,A){E.transform(k,function(){var j=r.apply(this,arguments),F=this.__zoom,C=U==null?I(j):typeof U=="function"?U.apply(this,arguments):U;return o(hl.translate(C[0],C[1]).scale(F.k).translate(typeof B=="function"?-B.apply(this,arguments):-B,typeof H=="function"?-H.apply(this,arguments):-H),j,f)},U,A)};function R(k,B){return B=Math.max(c[0],Math.min(c[1],B)),B===k.k?k:new vn(B,k.x,k.y)}function _(k,B,H){var U=B[0]-H[0]*k.k,A=B[1]-H[1]*k.k;return U===k.x&&A===k.y?k:new vn(k.k,U,A)}function I(k){return[(+k[0][0]+ +k[1][0])/2,(+k[0][1]+ +k[1][1])/2]}function W(k,B,H,U){k.on("start.zoom",function(){$(this,arguments).event(U).start()}).on("interrupt.zoom end.zoom",function(){$(this,arguments).event(U).end()}).tween("zoom",function(){var A=this,j=arguments,F=$(A,j).event(U),C=r.apply(A,j),z=H==null?I(C):typeof H=="function"?H.apply(A,j):H,ne=Math.max(C[1][0]-C[0][0],C[1][1]-C[0][1]),ie=A.__zoom,ce=typeof B=="function"?B.apply(A,j):B,O=p(ie.invert(z).concat(ne/ie.k),ce.invert(z).concat(ne/ce.k));return function(re){if(re===1)re=ce;else{var Q=O(re),le=ne/Q[2];re=new vn(le,z[0]-Q[0]*le,z[1]-Q[1]*le)}F.zoom(null,re)}})}function $(k,B,H){return!H&&k.__zooming||new V(k,B)}function V(k,B){this.that=k,this.args=B,this.active=0,this.sourceEvent=null,this.extent=r.apply(k,B),this.taps=0}V.prototype={event:function(k){return k&&(this.sourceEvent=k),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit("start")),this},zoom:function(k,B){return this.mouse&&k!=="mouse"&&(this.mouse[1]=B.invert(this.mouse[0])),this.touch0&&k!=="touch"&&(this.touch0[1]=B.invert(this.touch0[0])),this.touch1&&k!=="touch"&&(this.touch1[1]=B.invert(this.touch1[0])),this.that.__zoom=B,this.emit("zoom"),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit("end")),this},emit:function(k){var B=kt(this.that).datum();y.call(k,this.that,new nw(k,{sourceEvent:this.sourceEvent,target:E,transform:this.that.__zoom,dispatch:y}),B)}};function X(k,...B){if(!t.apply(this,arguments))return;var H=$(this,B).event(k),U=this.__zoom,A=Math.max(c[0],Math.min(c[1],U.k*Math.pow(2,l.apply(this,arguments)))),j=Bt(k);if(H.wheel)(H.mouse[0][0]!==j[0]||H.mouse[0][1]!==j[1])&&(H.mouse[1]=U.invert(H.mouse[0]=j)),clearTimeout(H.wheel);else{if(U.k===A)return;H.mouse=[j,U.invert(j)],Ks(this),H.start()}qi(k),H.wheel=setTimeout(F,S),H.zoom("mouse",o(_(R(U,A),H.mouse[0],H.mouse[1]),H.extent,f));function F(){H.wheel=null,H.end()}}function K(k,...B){if(g||!t.apply(this,arguments))return;var H=k.currentTarget,U=$(this,B,!0).event(k),A=kt(k.view).on("mousemove.zoom",z,!0).on("mouseup.zoom",ne,!0),j=Bt(k,H),F=k.clientX,C=k.clientY;dp(k.view),Sa(k),U.mouse=[j,this.__zoom.invert(j)],Ks(this),U.start();function z(ie){if(qi(ie),!U.moved){var ce=ie.clientX-F,O=ie.clientY-C;U.moved=ce*ce+O*O>M}U.event(ie).zoom("mouse",o(_(U.that.__zoom,U.mouse[0]=Bt(ie,H),U.mouse[1]),U.extent,f))}function ne(ie){A.on("mousemove.zoom mouseup.zoom",null),hp(ie.view,U.moved),qi(ie),U.event(ie).end()}}function te(k,...B){if(t.apply(this,arguments)){var H=this.__zoom,U=Bt(k.changedTouches?k.changedTouches[0]:k,this),A=H.invert(U),j=H.k*(k.shiftKey?.5:2),F=o(_(R(H,j),U,A),r.apply(this,B),f);qi(k),h>0?kt(this).transition().duration(h).call(W,F,U,k):kt(this).call(E.transform,F,U,k)}}function Z(k,...B){if(t.apply(this,arguments)){var H=k.touches,U=H.length,A=$(this,B,k.changedTouches.length===U).event(k),j,F,C,z;for(Sa(k),F=0;F`Seems like you have not used ${t==="svelte"?"SvelteFlowProvider":"ReactFlowProvider"} as an ancestor. Help: https://${t}flow.dev/error#001`,error002:()=>"It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.",error003:t=>`Node type "${t}" not found. Using fallback type "default".`,error004:()=>"The parent container needs a width and a height to render the graph.",error005:()=>"Only child nodes can use a parent extent.",error006:()=>"Can't create edge. An edge needs a source and a target.",error007:t=>`The old edge with id=${t} does not exist.`,error009:t=>`Marker type "${t}" doesn't exist.`,error008:(t,{id:r,sourceHandle:o,targetHandle:l})=>`Couldn't create edge for ${t} handle id: "${t==="source"?o:l}", edge id: ${r}.`,error010:()=>"Handle: No node id found. Make sure to only use a Handle inside a custom Node.",error011:t=>`Edge type "${t}" not found. Using fallback type "default".`,error012:t=>`Node with id "${t}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,error013:(t="react")=>`It seems that you haven't loaded the styles. Please import '@xyflow/${t}/dist/style.css' or base.css to make sure everything is working properly.`,error014:()=>"useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.",error015:()=>"It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs.",error016:t=>`Edge with id "${t}" does not exist, it may have been removed. This can happen when an edge is deleted before the "onEdgeClick" handler is called.`},so=[[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY],[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY]],Tp=["Enter"," ","Escape"],zp={"node.a11yDescription.default":"Press enter or space to select a node. Press delete to remove it and escape to cancel.","node.a11yDescription.keyboardDisabled":"Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.","node.a11yDescription.ariaLiveMessage":({direction:t,x:r,y:o})=>`Moved selected node ${t}. New position, x: ${r}, y: ${o}`,"edge.a11yDescription.default":"Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.","controls.ariaLabel":"Control Panel","controls.zoomIn.ariaLabel":"Zoom In","controls.zoomOut.ariaLabel":"Zoom Out","controls.fitView.ariaLabel":"Fit View","controls.interactive.ariaLabel":"Toggle Interactivity","minimap.ariaLabel":"Mini Map","handle.ariaLabel":"Handle"};var ni;(function(t){t.Strict="strict",t.Loose="loose"})(ni||(ni={}));var pr;(function(t){t.Free="free",t.Vertical="vertical",t.Horizontal="horizontal"})(pr||(pr={}));var lo;(function(t){t.Partial="partial",t.Full="full"})(lo||(lo={}));const Rp={inProgress:!1,isValid:null,from:null,fromHandle:null,fromPosition:null,fromNode:null,to:null,toHandle:null,toPosition:null,toNode:null,pointer:null};var Wn;(function(t){t.Bezier="default",t.Straight="straight",t.Step="step",t.SmoothStep="smoothstep",t.SimpleBezier="simplebezier"})(Wn||(Wn={}));var il;(function(t){t.Arrow="arrow",t.ArrowClosed="arrowclosed"})(il||(il={}));var xe;(function(t){t.Left="left",t.Top="top",t.Right="right",t.Bottom="bottom"})(xe||(xe={}));const sh={[xe.Left]:xe.Right,[xe.Right]:xe.Left,[xe.Top]:xe.Bottom,[xe.Bottom]:xe.Top};function Lp(t){return t===null?null:t?"valid":"invalid"}const jp=t=>!!t&&typeof t=="object"&&"id"in t&&"source"in t&&"target"in t,uw=t=>!!t&&typeof t=="object"&&"id"in t&&"position"in t&&!("source"in t)&&!("target"in t),Ja=t=>!!t&&typeof t=="object"&&"id"in t&&"internals"in t&&!("source"in t)&&!("target"in t),go=(t,r=[0,0])=>{const{width:o,height:l}=Qt(t),u=t.origin??r,c=o*u[0],f=l*u[1];return{x:t.position.x-c,y:t.position.y-f}},aw=(t,r={nodeOrigin:[0,0]})=>{if(t.length===0)return{x:0,y:0,width:0,height:0};let o=!1;const l=t.reduce((u,c)=>{const f=typeof c=="string";let h=!r.nodeLookup&&!f?c:void 0;return r.nodeLookup&&(h=f?r.nodeLookup.get(c):Ja(c)?c:r.nodeLookup.get(c.id)),h?(o=!0,pl(u,ol(h,r.nodeOrigin))):u},{x:1/0,y:1/0,x2:-1/0,y2:-1/0});return o?gl(l):{x:0,y:0,width:0,height:0}},mo=(t,r={})=>{let o={x:1/0,y:1/0,x2:-1/0,y2:-1/0},l=!1;return t.forEach(u=>{(r.filter===void 0||r.filter(u))&&(o=pl(o,ol(u)),l=!0)}),l?gl(o):{x:0,y:0,width:0,height:0}},ec=(t,r,[o,l,u]=[0,0,1],c=!1,f=!1)=>{const h=(r.x-o)/u,p=(r.y-l)/u,y=r.width/u,m=r.height/u,v=[];for(const g of t.values()){const{measured:w,selectable:S=!0,hidden:M=!1}=g;if(f&&!S||M)continue;const N=w.width??g.width??g.initialWidth??0,E=w.height??g.height??g.initialHeight??0,{x:R,y:_}=g.internals.positionAbsolute,I=Op(h,p,y,m,R,_,N,E),W=N*E,$=c&&I>0;(!g.internals.handleBounds||$||I>=W||g.dragging)&&v.push(g)}return v},cw=(t,r)=>{const o=new Set;return t.forEach(l=>{o.add(l.id)}),r.filter(l=>o.has(l.source)||o.has(l.target))};function fw(t,r){const o=new Map,l=r!=null&&r.nodes?new Set(r.nodes.map(u=>u.id)):null;return t.forEach(u=>{let c;if(r!=null&&r.includeHiddenNodes){const{width:f,height:h}=Qt(u);c=f>0&&h>0}else c=!!(u.measured.width&&u.measured.height&&!u.hidden);c&&(!l||l.has(u.id))&&o.set(u.id,u)}),o}async function dw({nodes:t,width:r,height:o,panZoom:l,minZoom:u,maxZoom:c},f){if(t.size===0)return!0;const h=fw(t,f),p=mo(h),y=nc(p,r,o,(f==null?void 0:f.minZoom)??u,(f==null?void 0:f.maxZoom)??c,(f==null?void 0:f.padding)??.1);return await l.setViewport(y,{duration:f==null?void 0:f.duration,ease:f==null?void 0:f.ease,interpolate:f==null?void 0:f.interpolate}),!0}function Ap({nodeId:t,nextPosition:r,nodeLookup:o,nodeOrigin:l=[0,0],nodeExtent:u,onError:c}){const f=o.get(t),h=f.parentId?o.get(f.parentId):void 0,{x:p,y}=h?h.internals.positionAbsolute:{x:0,y:0},m=f.origin??l;let v=f.extent||u;if(f.extent==="parent"&&!f.expandParent)if(!h)c==null||c("005",Yt.error005());else{const{width:w,height:S}=Qt(h);w&&S&&(v=[[p,y],[p+w,y+S]])}else h&&vr(f.extent)&&(v=[[f.extent[0][0]+p,f.extent[0][1]+y],[f.extent[1][0]+p,f.extent[1][1]+y]]);const g=vr(v)?yr(r,v,f.measured):r;return(f.measured.width===void 0||f.measured.height===void 0)&&(c==null||c("015",Yt.error015())),{position:{x:g.x-p+(f.measured.width??0)*m[0],y:g.y-y+(f.measured.height??0)*m[1]},positionAbsolute:g}}async function hw({nodesToRemove:t=[],edgesToRemove:r=[],nodes:o,edges:l,onBeforeDelete:u}){const c=new Set(t.map(g=>g.id)),f=[];for(const g of o){if(g.deletable===!1)continue;const w=c.has(g.id),S=!w&&g.parentId&&f.find(M=>M.id===g.parentId);(w||S)&&f.push(g)}const h=new Set(r.map(g=>g.id)),p=l.filter(g=>g.deletable!==!1),m=cw(f,p);for(const g of p)h.has(g.id)&&!m.find(S=>S.id===g.id)&&m.push(g);if(!u)return{edges:m,nodes:f};const v=await u({nodes:f,edges:m});return typeof v=="boolean"?v?{edges:m,nodes:f}:{edges:[],nodes:[]}:v}const ri=(t,r=0,o=1)=>Math.min(Math.max(t,r),o),yr=(t={x:0,y:0},r,o)=>({x:ri(t.x,r[0][0],r[1][0]-((o==null?void 0:o.width)??0)),y:ri(t.y,r[0][1],r[1][1]-((o==null?void 0:o.height)??0))});function $p(t,r,o){const{width:l,height:u}=Qt(o),{x:c,y:f}=o.internals.positionAbsolute;return yr(t,[[c,f],[c+l,f+u]],r)}const lh=(t,r,o)=>to?-ri(Math.abs(t-o),1,r)/r:0,tc=(t,r,o=15,l=40)=>{const u=lh(t.x,l,r.width-l)*o,c=lh(t.y,l,r.height-l)*o;return[u,c]},pl=(t,r)=>({x:Math.min(t.x,r.x),y:Math.min(t.y,r.y),x2:Math.max(t.x2,r.x2),y2:Math.max(t.y2,r.y2)}),Ha=({x:t,y:r,width:o,height:l})=>({x:t,y:r,x2:t+o,y2:r+l}),gl=({x:t,y:r,x2:o,y2:l})=>({x:t,y:r,width:o-t,height:l-r}),uo=(t,r=[0,0])=>{var u,c;const{x:o,y:l}=Ja(t)?t.internals.positionAbsolute:go(t,r);return{x:o,y:l,width:((u=t.measured)==null?void 0:u.width)??t.width??t.initialWidth??0,height:((c=t.measured)==null?void 0:c.height)??t.height??t.initialHeight??0}},ol=(t,r=[0,0])=>{var u,c;const{x:o,y:l}=Ja(t)?t.internals.positionAbsolute:go(t,r);return{x:o,y:l,x2:o+(((u=t.measured)==null?void 0:u.width)??t.width??t.initialWidth??0),y2:l+(((c=t.measured)==null?void 0:c.height)??t.height??t.initialHeight??0)}},Dp=(t,r)=>gl(pl(Ha(t),Ha(r))),Op=(t,r,o,l,u,c,f,h)=>{const p=Math.max(0,Math.min(t+o,u+f)-Math.max(t,u)),y=Math.max(0,Math.min(r+l,c+h)-Math.max(r,c));return Math.ceil(p*y)},sl=(t,r)=>Op(t.x,t.y,t.width,t.height,r.x,r.y,r.width,r.height),uh=t=>Wt(t.width)&&Wt(t.height)&&Wt(t.x)&&Wt(t.y),Wt=t=>!isNaN(t)&&isFinite(t),Fp=(t,r)=>(o,l)=>{},yo=(t,r=[1,1])=>({x:r[0]*Math.round(t.x/r[0]),y:r[1]*Math.round(t.y/r[1])}),vo=({x:t,y:r},[o,l,u],c=!1,f=[1,1])=>{const h={x:(t-o)/u,y:(r-l)/u};return c?yo(h,f):h},ii=({x:t,y:r},[o,l,u])=>({x:t*u+o,y:r*u+l});function Kr(t,r){if(typeof t=="number")return Math.floor((r-r/(1+t))*.5);if(typeof t=="string"&&t.endsWith("px")){const o=parseFloat(t);if(!Number.isNaN(o))return Math.floor(o)}if(typeof t=="string"&&t.endsWith("%")){const o=parseFloat(t);if(!Number.isNaN(o))return Math.floor(r*o*.01)}return console.error(`The padding value "${t}" is invalid. Please provide a number or a string with a valid unit (px or %).`),0}function pw(t,r,o){if(typeof t=="string"||typeof t=="number"){const l=Kr(t,o),u=Kr(t,r);return{top:l,right:u,bottom:l,left:u,x:u*2,y:l*2}}if(typeof t=="object"){const l=Kr(t.top??t.y??0,o),u=Kr(t.bottom??t.y??0,o),c=Kr(t.left??t.x??0,r),f=Kr(t.right??t.x??0,r);return{top:l,right:f,bottom:u,left:c,x:c+f,y:l+u}}return{top:0,right:0,bottom:0,left:0,x:0,y:0}}function gw(t,r,o,l,u,c){const{x:f,y:h}=ii(t,[r,o,l]),{x:p,y}=ii({x:t.x+t.width,y:t.y+t.height},[r,o,l]),m=u-p,v=c-y;return{left:Math.floor(f),top:Math.floor(h),right:Math.floor(m),bottom:Math.floor(v)}}const nc=(t,r,o,l,u,c)=>{const f=pw(c,r,o),h=(r-f.x)/t.width,p=(o-f.y)/t.height,y=Math.min(h,p),m=ri(y,l,u),v=t.x+t.width/2,g=t.y+t.height/2,w=r/2-v*m,S=o/2-g*m,M=gw(t,w,S,m,r,o),N={left:Math.min(M.left-f.left,0),top:Math.min(M.top-f.top,0),right:Math.min(M.right-f.right,0),bottom:Math.min(M.bottom-f.bottom,0)};return{x:w-N.left+N.right,y:S-N.top+N.bottom,zoom:m}},ao=()=>{var t;return typeof navigator<"u"&&((t=navigator==null?void 0:navigator.userAgent)==null?void 0:t.indexOf("Mac"))>=0};function vr(t){return t!=null&&t!=="parent"}function Qt(t){var r,o;return{width:((r=t.measured)==null?void 0:r.width)??t.width??t.initialWidth??0,height:((o=t.measured)==null?void 0:o.height)??t.height??t.initialHeight??0}}function Hp(t){var r,o;return(((r=t.measured)==null?void 0:r.width)??t.width??t.initialWidth)!==void 0&&(((o=t.measured)==null?void 0:o.height)??t.height??t.initialHeight)!==void 0}function Vp(t,r={width:0,height:0},o,l,u){const c={...t},f=l.get(o);if(f){const h=f.origin||u;c.x+=f.internals.positionAbsolute.x-(r.width??0)*h[0],c.y+=f.internals.positionAbsolute.y-(r.height??0)*h[1]}return c}function ah(t,r){if(t.size!==r.size)return!1;for(const o of t)if(!r.has(o))return!1;return!0}function mw(){let t,r;return{promise:new Promise((l,u)=>{t=l,r=u}),resolve:t,reject:r}}function yw(t){return{...zp,...t||{}}}function to(t,{snapGrid:r=[0,0],snapToGrid:o=!1,transform:l,containerBounds:u}){const{x:c,y:f}=bt(t),h=vo({x:c-((u==null?void 0:u.left)??0),y:f-((u==null?void 0:u.top)??0)},l),{x:p,y}=o?yo(h,r):h;return{xSnapped:p,ySnapped:y,...h}}const rc=t=>({width:t.offsetWidth,height:t.offsetHeight}),Bp=t=>{var r;return((r=t==null?void 0:t.getRootNode)==null?void 0:r.call(t))||(window==null?void 0:window.document)},vw=["INPUT","SELECT","TEXTAREA"];function Up(t){var l,u;const r=((u=(l=t.composedPath)==null?void 0:l.call(t))==null?void 0:u[0])||t.target;return(r==null?void 0:r.nodeType)!==1?!1:vw.includes(r.nodeName)||r.hasAttribute("contenteditable")||!!r.closest(".nokey")}const Wp=t=>"clientX"in t,bt=(t,r)=>{var c,f;const o=Wp(t),l=o?t.clientX:(c=t.touches)==null?void 0:c[0].clientX,u=o?t.clientY:(f=t.touches)==null?void 0:f[0].clientY;return{x:l-((r==null?void 0:r.left)??0),y:u-((r==null?void 0:r.top)??0)}},ch=(t,r,o,l,u)=>{const c=r.querySelectorAll(`.${t}`);return!c||!c.length?null:Array.from(c).map(f=>{const h=f.getBoundingClientRect();return{id:f.getAttribute("data-handleid"),type:t,nodeId:u,position:f.getAttribute("data-handlepos"),x:(h.left-o.left)/l,y:(h.top-o.top)/l,...rc(f)}})};function bp({sourceX:t,sourceY:r,targetX:o,targetY:l,sourceControlX:u,sourceControlY:c,targetControlX:f,targetControlY:h}){const p=t*.125+u*.375+f*.375+o*.125,y=r*.125+c*.375+h*.375+l*.125,m=Math.abs(p-t),v=Math.abs(y-r);return[p,y,m,v]}function Bs(t,r){return t>=0?.5*t:r*25*Math.sqrt(-t)}function fh({pos:t,x1:r,y1:o,x2:l,y2:u,c}){switch(t){case xe.Left:return[r-Bs(r-l,c),o];case xe.Right:return[r+Bs(l-r,c),o];case xe.Top:return[r,o-Bs(o-u,c)];case xe.Bottom:return[r,o+Bs(u-o,c)]}}function Yp({sourceX:t,sourceY:r,sourcePosition:o=xe.Bottom,targetX:l,targetY:u,targetPosition:c=xe.Top,curvature:f=.25}){const[h,p]=fh({pos:o,x1:t,y1:r,x2:l,y2:u,c:f}),[y,m]=fh({pos:c,x1:l,y1:u,x2:t,y2:r,c:f}),[v,g,w,S]=bp({sourceX:t,sourceY:r,targetX:l,targetY:u,sourceControlX:h,sourceControlY:p,targetControlX:y,targetControlY:m});return[`M${t},${r} C${h},${p} ${y},${m} ${l},${u}`,v,g,w,S]}function Xp({sourceX:t,sourceY:r,targetX:o,targetY:l}){const u=Math.abs(o-t)/2,c=o0}const Sw=({source:t,sourceHandle:r,target:o,targetHandle:l})=>`xy-edge__${t}${r||""}-${o}${l||""}`,_w=(t,r)=>r.some(o=>o.source===t.source&&o.target===t.target&&(o.sourceHandle===t.sourceHandle||!o.sourceHandle&&!t.sourceHandle)&&(o.targetHandle===t.targetHandle||!o.targetHandle&&!t.targetHandle)),Ew=(t,r,o={})=>{var c;if(!t.source||!t.target)return(c=o.onError)==null||c.call(o,"006",Yt.error006()),r;const l=o.getEdgeId||Sw;let u;return jp(t)?u={...t}:u={...t,id:l(t)},_w(u,r)?r:(u.sourceHandle===null&&delete u.sourceHandle,u.targetHandle===null&&delete u.targetHandle,r.concat(u))};function Qp({sourceX:t,sourceY:r,targetX:o,targetY:l}){const[u,c,f,h]=Xp({sourceX:t,sourceY:r,targetX:o,targetY:l});return[`M ${t},${r}L ${o},${l}`,u,c,f,h]}const dh={[xe.Left]:{x:-1,y:0},[xe.Right]:{x:1,y:0},[xe.Top]:{x:0,y:-1},[xe.Bottom]:{x:0,y:1}},kw=({source:t,sourcePosition:r=xe.Bottom,target:o})=>r===xe.Left||r===xe.Right?t.xMath.sqrt(Math.pow(r.x-t.x,2)+Math.pow(r.y-t.y,2));function Nw({source:t,sourcePosition:r=xe.Bottom,target:o,targetPosition:l=xe.Top,center:u,offset:c,stepPosition:f}){const h=dh[r],p=dh[l],y={x:t.x+h.x*c,y:t.y+h.y*c},m={x:o.x+p.x*c,y:o.y+p.y*c},v=kw({source:y,sourcePosition:r,target:m}),g=v.x!==0?"x":"y",w=v[g];let S=[],M,N;const E={x:0,y:0},R={x:0,y:0},[,,_,I]=Xp({sourceX:t.x,sourceY:t.y,targetX:o.x,targetY:o.y});if(h[g]*p[g]===-1){g==="x"?(M=u.x??y.x+(m.x-y.x)*f,N=u.y??(y.y+m.y)/2):(M=u.x??(y.x+m.x)/2,N=u.y??y.y+(m.y-y.y)*f);const X=[{x:M,y:y.y},{x:M,y:m.y}],K=[{x:y.x,y:N},{x:m.x,y:N}];h[g]===w?S=g==="x"?X:K:S=g==="x"?K:X}else{const X=[{x:y.x,y:m.y}],K=[{x:m.x,y:y.y}];if(g==="x"?S=h.x===w?K:X:S=h.y===w?X:K,r===l){const k=Math.abs(t[g]-o[g]);if(k<=c){const B=Math.min(c-1,c-k);h[g]===w?E[g]=(y[g]>t[g]?-1:1)*B:R[g]=(m[g]>o[g]?-1:1)*B}}if(r!==l){const k=g==="x"?"y":"x",B=h[g]===p[k],H=y[k]>m[k],U=y[k]=J?(M=(te.x+Z.x)/2,N=S[0].y):(M=S[0].x,N=(te.y+Z.y)/2)}const W={x:y.x+E.x,y:y.y+E.y},$={x:m.x+R.x,y:m.y+R.y};return[[t,...W.x!==S[0].x||W.y!==S[0].y?[W]:[],...S,...$.x!==S[S.length-1].x||$.y!==S[S.length-1].y?[$]:[],o],M,N,_,I]}function Cw(t,r,o,l){const u=Math.min(hh(t,r)/2,hh(r,o)/2,l),{x:c,y:f}=r;if(t.x===c&&c===o.x||t.y===f&&f===o.y)return`L${c} ${f}`;if(t.y===f){const y=t.xo.id===r):t[0])||null}function Ba(t,r){return t?typeof t=="string"?t:`${r?`${r}__`:""}${Object.keys(t).sort().map(l=>`${l}=${t[l]}`).join("&")}`:""}function Pw(t,{id:r,defaultColor:o,defaultMarkerStart:l,defaultMarkerEnd:u}){const c=new Set;return t.reduce((f,h)=>([h.markerStart||l,h.markerEnd||u].forEach(p=>{if(p&&typeof p=="object"){const y=Ba(p,r);c.has(y)||(f.push({id:y,color:p.color||o,...p}),c.add(y))}}),f),[]).sort((f,h)=>f.id.localeCompare(h.id))}const Gp=1e3,Iw=10,ic={nodeOrigin:[0,0],nodeExtent:so,elevateNodesOnSelect:!0,zIndexMode:"basic",defaults:{}},Tw={...ic,checkEquality:!0};function oc(t,r){const o={...t};for(const l in r)r[l]!==void 0&&(o[l]=r[l]);return o}function zw(t,r,o){const l=oc(ic,o);for(const u of t.values())if(u.parentId)lc(u,t,r,l);else{const c=go(u,l.nodeOrigin),f=vr(u.extent)?u.extent:l.nodeExtent,h=yr(c,f,Qt(u));u.internals.positionAbsolute=h}}function Rw(t,r){if(!t.handles)return t.measured?r==null?void 0:r.internals.handleBounds:void 0;const o=[],l=[];for(const u of t.handles){const c={id:u.id,width:u.width??1,height:u.height??1,nodeId:t.id,x:u.x,y:u.y,position:u.position,type:u.type};u.type==="source"?o.push(c):u.type==="target"&&l.push(c)}return{source:o,target:l}}function sc(t){return t==="manual"}function Ua(t,r,o,l={}){var m,v;const u=oc(Tw,l),c={i:0},f=new Map(r),h=u!=null&&u.elevateNodesOnSelect&&!sc(u.zIndexMode)?Gp:0;let p=t.length>0,y=!1;r.clear(),o.clear();for(const g of t){let w=f.get(g.id);if(u.checkEquality&&g===(w==null?void 0:w.internals.userNode))r.set(g.id,w);else{const S=go(g,u.nodeOrigin),M=vr(g.extent)?g.extent:u.nodeExtent,N=yr(S,M,Qt(g));w={...u.defaults,...g,measured:{width:(m=g.measured)==null?void 0:m.width,height:(v=g.measured)==null?void 0:v.height},internals:{positionAbsolute:N,handleBounds:Rw(g,w),z:Kp(g,h,u.zIndexMode),userNode:g}},r.set(g.id,w)}(w.measured===void 0||w.measured.width===void 0||w.measured.height===void 0)&&!w.hidden&&(p=!1),g.parentId&&lc(w,r,o,l,c),y||(y=g.selected??!1)}return{nodesInitialized:p,hasSelectedNodes:y}}function Lw(t,r){if(!t.parentId)return;const o=r.get(t.parentId);o?o.set(t.id,t):r.set(t.parentId,new Map([[t.id,t]]))}function lc(t,r,o,l,u){const{elevateNodesOnSelect:c,nodeOrigin:f,nodeExtent:h,zIndexMode:p}=oc(ic,l),y=t.parentId,m=r.get(y);if(!m){console.warn(`Parent node ${y} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`);return}Lw(t,o),u&&!m.parentId&&m.internals.rootParentIndex===void 0&&p==="auto"&&(m.internals.rootParentIndex=++u.i,m.internals.z=m.internals.z+u.i*Iw),u&&m.internals.rootParentIndex!==void 0&&(u.i=m.internals.rootParentIndex);const v=c&&!sc(p)?Gp:0,{x:g,y:w,z:S}=jw(t,m,f,h,v,p),{positionAbsolute:M}=t.internals,N=g!==M.x||w!==M.y;(N||S!==t.internals.z)&&r.set(t.id,{...t,internals:{...t.internals,positionAbsolute:N?{x:g,y:w}:M,z:S}})}function Kp(t,r,o){const l=Wt(t.zIndex)?t.zIndex:0;return sc(o)?l:l+(t.selected?r:0)}function jw(t,r,o,l,u,c){const{x:f,y:h}=r.internals.positionAbsolute,p=Qt(t),y=go(t,o),m=vr(t.extent)?yr(y,t.extent,p):y;let v=yr({x:f+m.x,y:h+m.y},l,p);t.extent==="parent"&&(v=$p(v,p,r));const g=Kp(t,u,c),w=r.internals.z??0;return{x:v.x,y:v.y,z:w>=g?w+1:g}}function uc(t,r,o,l=[0,0]){var f;const u=[],c=new Map;for(const h of t){const p=r.get(h.parentId);if(!p)continue;const y=((f=c.get(h.parentId))==null?void 0:f.expandedRect)??uo(p),m=Dp(y,h.rect);c.set(h.parentId,{expandedRect:m,parent:p})}return c.size>0&&c.forEach(({expandedRect:h,parent:p},y)=>{var _;const m=p.internals.positionAbsolute,v=Qt(p),g=p.origin??l,w=h.x0||S>0||E||R)&&(u.push({id:y,type:"position",position:{x:p.position.x-w+E,y:p.position.y-S+R}}),(_=o.get(y))==null||_.forEach(I=>{t.some(W=>W.id===I.id)||u.push({id:I.id,type:"position",position:{x:I.position.x+w,y:I.position.y+S}})})),(v.width0){const w=uc(g,r,o,u);y.push(...w)}return{changes:y,updatedInternals:p}}async function $w({delta:t,panZoom:r,transform:o,translateExtent:l,width:u,height:c}){if(!r||!t.x&&!t.y)return!1;const f=await r.setViewportConstrained({x:o[0]+t.x,y:o[1]+t.y,zoom:o[2]},[[0,0],[u,c]],l);return!!f&&(f.x!==o[0]||f.y!==o[1]||f.k!==o[2])}function yh(t,r,o,l,u,c){let f=u;const h=l.get(f)||new Map;l.set(f,h.set(o,r)),f=`${u}-${t}`;const p=l.get(f)||new Map;if(l.set(f,p.set(o,r)),c){f=`${u}-${t}-${c}`;const y=l.get(f)||new Map;l.set(f,y.set(o,r))}}function qp(t,r,o){t.clear(),r.clear();for(const l of o){const{source:u,target:c,sourceHandle:f=null,targetHandle:h=null}=l,p={edgeId:l.id,source:u,target:c,sourceHandle:f,targetHandle:h},y=`${u}-${f}--${c}-${h}`,m=`${c}-${h}--${u}-${f}`;yh("source",p,m,t,u,f),yh("target",p,y,t,c,h),r.set(l.id,l)}}function Zp(t,r){if(!t.parentId)return!1;const o=r.get(t.parentId);return o?o.selected?!0:Zp(o,r):!1}function vh(t,r,o){var u;let l=t;do{if((u=l==null?void 0:l.matches)!=null&&u.call(l,r))return!0;if(l===o)return!1;l=l==null?void 0:l.parentElement}while(l);return!1}function Dw(t,r,o,l){const u=new Map;for(const[c,f]of t)if((f.selected||f.id===l)&&(!f.parentId||!Zp(f,t))&&(f.draggable||r&&typeof f.draggable>"u")){const h=t.get(c);h&&u.set(c,{id:c,position:h.position||{x:0,y:0},distance:{x:o.x-h.internals.positionAbsolute.x,y:o.y-h.internals.positionAbsolute.y},extent:h.extent,parentId:h.parentId,origin:h.origin,expandParent:h.expandParent,internals:{positionAbsolute:h.internals.positionAbsolute||{x:0,y:0}},measured:{width:h.measured.width??0,height:h.measured.height??0}})}return u}function _a({nodeId:t,dragItems:r,nodeLookup:o,dragging:l=!0}){var f,h,p;const u=[];for(const[y,m]of r){const v=(f=o.get(y))==null?void 0:f.internals.userNode;v&&u.push({...v,position:m.position,dragging:l})}if(!t)return[u[0],u];const c=(h=o.get(t))==null?void 0:h.internals.userNode;return[c?{...c,position:((p=r.get(t))==null?void 0:p.position)||c.position,dragging:l}:u[0],u]}function Ow({dragItems:t,snapGrid:r,x:o,y:l}){const u=t.values().next().value;if(!u)return null;const c={x:o-u.distance.x,y:l-u.distance.y},f=yo(c,r);return{x:f.x-c.x,y:f.y-c.y}}function Fw({onNodeMouseDown:t,getStoreItems:r,onDragStart:o,onDrag:l,onDragStop:u}){let c={x:null,y:null},f=0,h=new Map,p=!1,y={x:0,y:0},m=null,v=!1,g=null,w=!1,S=!1,M=null;function N({noDragClassName:R,handleSelector:_,domNode:I,isSelectable:W,nodeId:$,nodeClickDistance:V=0}){g=kt(I);function X({x:ee,y:J}){const{nodeLookup:k,nodeExtent:B,snapGrid:H,snapToGrid:U,nodeOrigin:A,onNodeDrag:j,onSelectionDrag:F,onError:C,updateNodePositions:z}=r();c={x:ee,y:J};let ne=!1;const ie=h.size>1,ce=ie&&B?Ha(mo(h)):null,O=ie&&U?Ow({dragItems:h,snapGrid:H,x:ee,y:J}):null;for(const[re,Q]of h){if(!k.has(re))continue;let le={x:ee-Q.distance.x,y:J-Q.distance.y};U&&(le=O?{x:Math.round(le.x+O.x),y:Math.round(le.y+O.y)}:yo(le,H));let pe=null;if(ie&&B&&!Q.extent&&ce){const{positionAbsolute:me}=Q.internals,ke=me.x-ce.x+B[0][0],Ce=me.x+Q.measured.width-ce.x2+B[1][0],Me=me.y-ce.y+B[0][1],Re=me.y+Q.measured.height-ce.y2+B[1][1];pe=[[ke,Me],[Ce,Re]]}const{position:we,positionAbsolute:ve}=Ap({nodeId:re,nextPosition:le,nodeLookup:k,nodeExtent:pe||B,nodeOrigin:A,onError:C});ne=ne||Q.position.x!==we.x||Q.position.y!==we.y,Q.position=we,Q.internals.positionAbsolute=ve}if(S=S||ne,!!ne&&(z(h,!0),M&&(l||j||!$&&F))){const[re,Q]=_a({nodeId:$,dragItems:h,nodeLookup:k});l==null||l(M,h,re,Q),j==null||j(M,re,Q),$||F==null||F(M,Q)}}async function K(){if(!m)return;const{transform:ee,panBy:J,autoPanSpeed:k,autoPanOnNodeDrag:B}=r();if(!B){p=!1,cancelAnimationFrame(f);return}const[H,U]=tc(y,m,k);(H!==0||U!==0)&&(c.x=(c.x??0)-H/ee[2],c.y=(c.y??0)-U/ee[2],await J({x:H,y:U})&&X(c)),f=requestAnimationFrame(K)}function te(ee){var ie;const{nodeLookup:J,multiSelectionActive:k,nodesDraggable:B,transform:H,snapGrid:U,snapToGrid:A,selectNodesOnDrag:j,onNodeDragStart:F,onSelectionDragStart:C,unselectNodesAndEdges:z}=r();v=!0,(!j||!W)&&!k&&$&&((ie=J.get($))!=null&&ie.selected||z()),W&&j&&$&&(t==null||t($));const ne=to(ee.sourceEvent,{transform:H,snapGrid:U,snapToGrid:A,containerBounds:m});if(c=ne,h=Dw(J,B,ne,$),h.size>0&&(o||F||!$&&C)){const[ce,O]=_a({nodeId:$,dragItems:h,nodeLookup:J});o==null||o(ee.sourceEvent,h,ce,O),F==null||F(ee.sourceEvent,ce,O),$||C==null||C(ee.sourceEvent,O)}}const Z=pp().clickDistance(V).on("start",ee=>{const{domNode:J,nodeDragThreshold:k,transform:B,snapGrid:H,snapToGrid:U}=r();m=(J==null?void 0:J.getBoundingClientRect())||null,w=!1,S=!1,M=ee.sourceEvent,k===0&&te(ee),c=to(ee.sourceEvent,{transform:B,snapGrid:H,snapToGrid:U,containerBounds:m}),y=bt(ee.sourceEvent,m)}).on("drag",ee=>{const{autoPanOnNodeDrag:J,transform:k,snapGrid:B,snapToGrid:H,nodeDragThreshold:U,nodeLookup:A}=r(),j=to(ee.sourceEvent,{transform:k,snapGrid:B,snapToGrid:H,containerBounds:m});if(M=ee.sourceEvent,(ee.sourceEvent.type==="touchmove"&&ee.sourceEvent.touches.length>1||$&&!A.has($))&&(w=!0),!w){if(!p&&J&&v&&(p=!0,K()),!v){const F=bt(ee.sourceEvent,m),C=F.x-y.x,z=F.y-y.y;Math.sqrt(C*C+z*z)>U&&te(ee)}(c.x!==j.xSnapped||c.y!==j.ySnapped)&&h&&v&&(y=bt(ee.sourceEvent,m),X(j))}}).on("end",ee=>{if(!v||w){w&&h.size>0&&r().updateNodePositions(h,!1);return}if(p=!1,v=!1,cancelAnimationFrame(f),h.size>0){const{nodeLookup:J,updateNodePositions:k,onNodeDragStop:B,onSelectionDragStop:H}=r();if(S&&(k(h,!1),S=!1),u||B||!$&&H){const[U,A]=_a({nodeId:$,dragItems:h,nodeLookup:J,dragging:!1});u==null||u(ee.sourceEvent,h,U,A),B==null||B(ee.sourceEvent,U,A),$||H==null||H(ee.sourceEvent,A)}}}).filter(ee=>{const J=ee.target;return!ee.button&&(!R||!vh(J,`.${R}`,I))&&(!_||vh(J,_,I))});g.call(Z)}function E(){g==null||g.on(".drag",null)}return{update:N,destroy:E}}function Hw(t,r,o){const l=[],u={x:t.x-o,y:t.y-o,width:o*2,height:o*2};for(const c of r.values())sl(u,uo(c))>0&&l.push(c);return l}const Vw=250;function Bw(t,r,o,l){var h,p;let u=[],c=1/0;const f=Hw(t,o,r+Vw);for(const y of f){const m=[...((h=y.internals.handleBounds)==null?void 0:h.source)??[],...((p=y.internals.handleBounds)==null?void 0:p.target)??[]];for(const v of m){if(l.nodeId===v.nodeId&&l.type===v.type&&l.id===v.id)continue;const{x:g,y:w}=xr(y,v,v.position,!0),S=Math.sqrt(Math.pow(g-t.x,2)+Math.pow(w-t.y,2));S>r||(S1){const y=l.type==="source"?"target":"source";return u.find(m=>m.type===y)??u[0]}return u[0]}function Jp(t,r,o,l,u,c=!1){var y,m,v;const f=l.get(t);if(!f)return null;const h=u==="strict"?(y=f.internals.handleBounds)==null?void 0:y[r]:[...((m=f.internals.handleBounds)==null?void 0:m.source)??[],...((v=f.internals.handleBounds)==null?void 0:v.target)??[]],p=(o?h==null?void 0:h.find(g=>g.id===o):h==null?void 0:h[0])??null;return p&&c?{...p,...xr(f,p,p.position,!0)}:p}function eg(t,r){return t||(r!=null&&r.classList.contains("target")?"target":r!=null&&r.classList.contains("source")?"source":null)}function Uw(t,r){let o=null;return r?o=!0:t&&!r&&(o=!1),o}const tg=()=>!0;function Ww(t,{connectionMode:r,connectionRadius:o,handleId:l,nodeId:u,edgeUpdaterType:c,isTarget:f,domNode:h,nodeLookup:p,lib:y,autoPanOnConnect:m,flowId:v,panBy:g,cancelConnection:w,onConnectStart:S,onConnect:M,onConnectEnd:N,isValidConnection:E=tg,onReconnectEnd:R,updateConnection:_,getTransform:I,getFromHandle:W,autoPanSpeed:$,dragThreshold:V=1,handleDomNode:X}){const K=Bp(t.target);let te=0,Z;const{x:ee,y:J}=bt(t),k=eg(c,X),B=h==null?void 0:h.getBoundingClientRect();let H=!1;if(!B||!k)return;const U=Jp(u,k,l,p,r);if(!U)return;let A=bt(t,B),j=!1,F=null,C=!1,z=null;function ne(){if(!m||!B)return;const[we,ve]=tc(A,B,$);g({x:we,y:ve}),te=requestAnimationFrame(ne)}const ie={...U,nodeId:u,type:k,position:U.position},ce=p.get(u);let re={inProgress:!0,isValid:null,from:xr(ce,ie,xe.Left,!0),fromHandle:ie,fromPosition:ie.position,fromNode:ce,to:A,toHandle:null,toPosition:sh[ie.position],toNode:null,pointer:A};function Q(){H=!0,_(re),S==null||S(t,{nodeId:u,handleId:l,handleType:k})}V===0&&Q();function le(we){if(!H){const{x:Re,y:ot}=bt(we),ut=Re-ee,Je=ot-J;if(!(ut*ut+Je*Je>V*V))return;Q()}if(!W()||!ie){pe(we);return}const ve=I();A=bt(we,B),Z=Bw(vo(A,ve,!1,[1,1]),o,p,ie),j||(ne(),j=!0);const me=ng(we,{handle:Z,connectionMode:r,fromNodeId:u,fromHandleId:l,fromType:f?"target":"source",isValidConnection:E,doc:K,lib:y,flowId:v,nodeLookup:p});z=me.handleDomNode,F=me.connection,C=Uw(!!Z,me.isValid);const ke=p.get(u),Ce=ke?xr(ke,ie,xe.Left,!0):re.from,Me={...re,from:Ce,isValid:C,to:me.toHandle&&C?ii({x:me.toHandle.x,y:me.toHandle.y},ve):A,toHandle:me.toHandle,toPosition:C&&me.toHandle?me.toHandle.position:sh[ie.position],toNode:me.toHandle?p.get(me.toHandle.nodeId):null,pointer:A};_(Me),re=Me}function pe(we){if(!("touches"in we&&we.touches.length>0)){if(H){(Z||z)&&F&&C&&(M==null||M(F));const{inProgress:ve,...me}=re,ke={...me,toPosition:re.toHandle?re.toPosition:null};N==null||N(we,ke),c&&(R==null||R(we,ke))}w(),cancelAnimationFrame(te),j=!1,C=!1,F=null,z=null,K.removeEventListener("mousemove",le),K.removeEventListener("mouseup",pe),K.removeEventListener("touchmove",le),K.removeEventListener("touchend",pe)}}K.addEventListener("mousemove",le),K.addEventListener("mouseup",pe),K.addEventListener("touchmove",le),K.addEventListener("touchend",pe)}function ng(t,{handle:r,connectionMode:o,fromNodeId:l,fromHandleId:u,fromType:c,doc:f,lib:h,flowId:p,isValidConnection:y=tg,nodeLookup:m}){const v=c==="target",g=r?f.querySelector(`.${h}-flow__handle[data-id="${p}-${r==null?void 0:r.nodeId}-${r==null?void 0:r.id}-${r==null?void 0:r.type}"]`):null,{x:w,y:S}=bt(t),M=f.elementFromPoint(w,S),N=M!=null&&M.classList.contains(`${h}-flow__handle`)?M:g,E={handleDomNode:N,isValid:!1,connection:null,toHandle:null};if(N){const R=eg(void 0,N),_=N.getAttribute("data-nodeid"),I=N.getAttribute("data-handleid"),W=N.classList.contains("connectable"),$=N.classList.contains("connectableend");if(!_||!R)return E;const V={source:v?_:l,sourceHandle:v?I:u,target:v?l:_,targetHandle:v?u:I};E.connection=V;const K=W&&$&&(o===ni.Strict?v&&R==="source"||!v&&R==="target":_!==l||I!==u);E.isValid=K&&y(V),E.toHandle=Jp(_,R,I,m,o,!0)}return E}const Wa={onPointerDown:Ww,isValid:ng};function bw({domNode:t,panZoom:r,getTransform:o,getViewScale:l}){const u=kt(t);function c({translateExtent:h,width:p,height:y,zoomStep:m=1,pannable:v=!0,zoomable:g=!0,inversePan:w=!1}){const S=_=>{if(_.sourceEvent.type!=="wheel"||!r)return;const I=o(),W=_.sourceEvent.ctrlKey&&ao()?10:1,$=-_.sourceEvent.deltaY*(_.sourceEvent.deltaMode===1?.05:_.sourceEvent.deltaMode?1:.002)*m,V=I[2]*Math.pow(2,$*W);r.scaleTo(V)};let M=[0,0];const N=_=>{(_.sourceEvent.type==="mousedown"||_.sourceEvent.type==="touchstart")&&(M=[_.sourceEvent.clientX??_.sourceEvent.touches[0].clientX,_.sourceEvent.clientY??_.sourceEvent.touches[0].clientY])},E=_=>{const I=o();if(_.sourceEvent.type!=="mousemove"&&_.sourceEvent.type!=="touchmove"||!r)return;const W=[_.sourceEvent.clientX??_.sourceEvent.touches[0].clientX,_.sourceEvent.clientY??_.sourceEvent.touches[0].clientY],$=[W[0]-M[0],W[1]-M[1]];M=W;const V=l()*Math.max(I[2],Math.log(I[2]))*(w?-1:1),X={x:I[0]-$[0]*V,y:I[1]-$[1]*V},K=[[0,0],[p,y]];r.setViewportConstrained({x:X.x,y:X.y,zoom:I[2]},K,h)},R=Ip().on("start",N).on("zoom",v?E:null).on("zoom.wheel",g?S:null);u.call(R,{})}function f(){u.on("zoom",null)}return{update:c,destroy:f,pointer:Bt}}const ml=t=>({x:t.x,y:t.y,zoom:t.k}),Ea=({x:t,y:r,zoom:o})=>hl.translate(t,r).scale(o),Un=(t,r)=>t.target.closest(`.${r}`),rg=(t,r)=>r===2&&Array.isArray(t)&&t.includes(2),Yw=t=>((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2,ka=(t,r=0,o=Yw,l=()=>{})=>{const u=typeof r=="number"&&r>0;return u||l(),u?t.transition().duration(r).ease(o).on("end",l):t},ig=t=>{const r=t.ctrlKey&&ao()?10:1;return-t.deltaY*(t.deltaMode===1?.05:t.deltaMode?1:.002)*r};function Xw({zoomPanValues:t,noWheelClassName:r,d3Selection:o,d3Zoom:l,panOnScrollMode:u,panOnScrollSpeed:c,zoomOnPinch:f,onPanZoomStart:h,onPanZoom:p,onPanZoomEnd:y}){return m=>{if(Un(m,r))return m.ctrlKey&&m.preventDefault(),!1;m.preventDefault(),m.stopImmediatePropagation();const v=o.property("__zoom").k||1;if(m.ctrlKey&&f){const N=Bt(m),E=ig(m),R=v*Math.pow(2,E);l.scaleTo(o,R,N,m);return}const g=m.deltaMode===1?20:1;let w=u===pr.Vertical?0:m.deltaX*g,S=u===pr.Horizontal?0:m.deltaY*g;!ao()&&m.shiftKey&&u!==pr.Vertical&&(w=m.deltaY*g,S=0),l.translateBy(o,-(w/v)*c,-(S/v)*c,{internal:!0});const M=ml(o.property("__zoom"));clearTimeout(t.panScrollTimeout),t.isPanScrolling?p==null||p(m,M):(t.isPanScrolling=!0,h==null||h(m,M)),t.panScrollTimeout=setTimeout(()=>{y==null||y(m,M),t.isPanScrolling=!1},150)}}function Qw({noWheelClassName:t,preventScrolling:r,d3ZoomHandler:o}){return function(l,u){const c=l.type==="wheel",f=!r&&c&&!l.ctrlKey,h=Un(l,t);if(l.ctrlKey&&c&&h&&l.preventDefault(),f||h)return null;l.preventDefault(),o.call(this,l,u)}}function Gw({zoomPanValues:t,onDraggingChange:r,onPanZoomStart:o}){return l=>{var c,f,h;if((c=l.sourceEvent)!=null&&c.internal)return;const u=ml(l.transform);t.mouseButton=((f=l.sourceEvent)==null?void 0:f.button)||0,t.isZoomingOrPanning=!0,t.prevViewport=u,((h=l.sourceEvent)==null?void 0:h.type)==="mousedown"&&r(!0),o&&(o==null||o(l.sourceEvent,u))}}function Kw({zoomPanValues:t,panOnDrag:r,onPaneContextMenu:o,onTransformChange:l,onPanZoom:u}){return c=>{var f,h;t.usedRightMouseButton=!!(o&&rg(r,t.mouseButton??0)),(f=c.sourceEvent)!=null&&f.sync||l([c.transform.x,c.transform.y,c.transform.k]),u&&!((h=c.sourceEvent)!=null&&h.internal)&&(u==null||u(c.sourceEvent,ml(c.transform)))}}function qw({zoomPanValues:t,panOnDrag:r,panOnScroll:o,onDraggingChange:l,onPanZoomEnd:u,onPaneContextMenu:c}){return f=>{var h;if(!((h=f.sourceEvent)!=null&&h.internal)&&(t.isZoomingOrPanning=!1,c&&rg(r,t.mouseButton??0)&&!t.usedRightMouseButton&&f.sourceEvent&&c(f.sourceEvent),t.usedRightMouseButton=!1,l(!1),u)){const p=ml(f.transform);t.prevViewport=p,clearTimeout(t.timerId),t.timerId=setTimeout(()=>{u==null||u(f.sourceEvent,p)},o?150:0)}}}function Zw({panActivationKeyPressed:t,zoomActivationKeyPressed:r,zoomOnScroll:o,zoomOnPinch:l,panOnDrag:u,panOnScroll:c,zoomOnDoubleClick:f,userSelectionActive:h,noWheelClassName:p,noPanClassName:y,lib:m,connectionInProgress:v}){return g=>{var E;const w=r||o,S=l&&g.ctrlKey,M=g.type==="wheel";if(g.button===1&&g.type==="mousedown"&&(Un(g,`${m}-flow__node`)||Un(g,`${m}-flow__edge`)||Un(g,`${m}-flow__selection`)||Un(g,`${m}-flow__nodesselection`)))return!0;if(!u&&!w&&!c&&!f&&!l||h||v&&!M||Un(g,p)&&M||Un(g,y)&&(!M||c&&M&&!r)||!l&&g.ctrlKey&&M)return!1;if(!l&&g.type==="touchstart"&&((E=g.touches)==null?void 0:E.length)>1)return g.preventDefault(),!1;if(!w&&!c&&!S&&M||!u&&(g.type==="mousedown"||g.type==="touchstart")||Array.isArray(u)&&!u.includes(g.button)&&g.type==="mousedown")return!1;const N=Array.isArray(u)&&u.includes(g.button)||!g.button||g.button<=1;return(!g.ctrlKey||M||t)&&N}}function Jw({domNode:t,minZoom:r,maxZoom:o,translateExtent:l,viewport:u,onPanZoom:c,onPanZoomStart:f,onPanZoomEnd:h,onDraggingChange:p}){const y={isZoomingOrPanning:!1,usedRightMouseButton:!1,prevViewport:{},mouseButton:0,timerId:void 0,panScrollTimeout:void 0,isPanScrolling:!1},m=t.getBoundingClientRect();let v=[[0,0],[m.width,m.height]];const g=typeof ResizeObserver<"u"?new ResizeObserver(J=>{const k=J[0];k&&(v=[[0,0],[k.contentRect.width,k.contentRect.height]])}):null;g==null||g.observe(t);const w=Ip().extent(()=>v).scaleExtent([r,o]).translateExtent(l),S=kt(t).call(w);I({x:u.x,y:u.y,zoom:ri(u.zoom,r,o)},[[0,0],[m.width,m.height]],l);const M=S.on("wheel.zoom"),N=S.on("dblclick.zoom");w.wheelDelta(ig);async function E(J,k){return S?new Promise(B=>{w==null||w.interpolate((k==null?void 0:k.interpolate)==="linear"?eo:Xs).transform(ka(S,k==null?void 0:k.duration,k==null?void 0:k.ease,()=>B(!0)),J)}):!1}function R({noWheelClassName:J,noPanClassName:k,onPaneContextMenu:B,userSelectionActive:H,panOnScroll:U,panOnDrag:A,panOnScrollMode:j,panOnScrollSpeed:F,preventScrolling:C,zoomOnPinch:z,zoomOnScroll:ne,zoomOnDoubleClick:ie,panActivationKeyPressed:ce=!1,zoomActivationKeyPressed:O,lib:re,onTransformChange:Q,connectionInProgress:le,paneClickDistance:pe,selectionOnDrag:we}){H&&!y.isZoomingOrPanning&&_();const ve=U&&!O&&!H;w.clickDistance(we?1/0:!Wt(pe)||pe<0?0:pe);const me=ve?Xw({zoomPanValues:y,noWheelClassName:J,d3Selection:S,d3Zoom:w,panOnScrollMode:j,panOnScrollSpeed:F,zoomOnPinch:z,onPanZoomStart:f,onPanZoom:c,onPanZoomEnd:h}):Qw({noWheelClassName:J,preventScrolling:C,d3ZoomHandler:M});S.on("wheel.zoom",me,{passive:!1});const ke=Gw({zoomPanValues:y,onDraggingChange:p,onPanZoomStart:f});w.on("start",ke);const Ce=Kw({zoomPanValues:y,panOnDrag:A,onPaneContextMenu:!!B,onPanZoom:c,onTransformChange:Q});w.on("zoom",Ce);const Me=qw({zoomPanValues:y,panOnDrag:A,panOnScroll:U,onPaneContextMenu:B,onPanZoomEnd:h,onDraggingChange:p});w.on("end",Me);const Re=Zw({panActivationKeyPressed:ce,zoomActivationKeyPressed:O,panOnDrag:A,zoomOnScroll:ne,panOnScroll:U,zoomOnDoubleClick:ie,zoomOnPinch:z,userSelectionActive:H,noPanClassName:k,noWheelClassName:J,lib:re,connectionInProgress:le});w.filter(Re),ie?S.on("dblclick.zoom",N):S.on("dblclick.zoom",null)}function _(){w.on("zoom",null)}async function I(J,k,B){const H=Ea(J),U=w==null?void 0:w.constrain()(H,k,B);return U&&await E(U),U}async function W(J,k){const B=Ea(J);return await E(B,k),B}function $(J){if(S){const k=Ea(J),B=S.property("__zoom");(B.k!==J.zoom||B.x!==J.x||B.y!==J.y)&&(w==null||w.transform(S,k,null,{sync:!0}))}}function V(){const J=S?Pp(S.node()):{x:0,y:0,k:1};return{x:J.x,y:J.y,zoom:J.k}}async function X(J,k){return S?new Promise(B=>{w==null||w.interpolate((k==null?void 0:k.interpolate)==="linear"?eo:Xs).scaleTo(ka(S,k==null?void 0:k.duration,k==null?void 0:k.ease,()=>B(!0)),J)}):!1}async function K(J,k){return S?new Promise(B=>{w==null||w.interpolate((k==null?void 0:k.interpolate)==="linear"?eo:Xs).scaleBy(ka(S,k==null?void 0:k.duration,k==null?void 0:k.ease,()=>B(!0)),J)}):!1}function te(J){w==null||w.scaleExtent(J)}function Z(J){w==null||w.translateExtent(J)}function ee(J){const k=!Wt(J)||J<0?0:J;w==null||w.clickDistance(k)}return{update:R,destroy:_,setViewport:W,setViewportConstrained:I,getViewport:V,scaleTo:X,scaleBy:K,setScaleExtent:te,setTranslateExtent:Z,syncViewport:$,setClickDistance:ee}}var oi;(function(t){t.Line="line",t.Handle="handle"})(oi||(oi={}));function e1({width:t,prevWidth:r,height:o,prevHeight:l,affectsX:u,affectsY:c}){const f=t-r,h=o-l,p=[f>0?1:f<0?-1:0,h>0?1:h<0?-1:0];return f&&u&&(p[0]=p[0]*-1),h&&c&&(p[1]=p[1]*-1),p}function xh(t){const r=t.includes("right")||t.includes("left"),o=t.includes("bottom")||t.includes("top"),l=t.includes("left"),u=t.includes("top");return{isHorizontal:r,isVertical:o,affectsX:l,affectsY:u}}function Vn(t,r){return Math.max(0,r-t)}function Bn(t,r){return Math.max(0,t-r)}function Us(t,r,o){return Math.max(0,r-t,t-o)}function wh(t,r){return t?!r:r}function t1(t,r,o,l,u,c,f,h){let{affectsX:p,affectsY:y}=r;const{isHorizontal:m,isVertical:v}=r,g=m&&v,{xSnapped:w,ySnapped:S}=o,{minWidth:M,maxWidth:N,minHeight:E,maxHeight:R}=l,{x:_,y:I,width:W,height:$,aspectRatio:V}=t;let X=Math.floor(m?w-t.pointerX:0),K=Math.floor(v?S-t.pointerY:0);const te=W+(p?-X:X),Z=$+(y?-K:K),ee=-c[0]*W,J=-c[1]*$;let k=Us(te,M,N),B=Us(Z,E,R);if(f){let A=0,j=0;p&&X<0?A=Vn(_+X+ee,f[0][0]):!p&&X>0&&(A=Bn(_+te+ee,f[1][0])),y&&K<0?j=Vn(I+K+J,f[0][1]):!y&&K>0&&(j=Bn(I+Z+J,f[1][1])),k=Math.max(k,A),B=Math.max(B,j)}if(h){let A=0,j=0;p&&X>0?A=Bn(_+X,h[0][0]):!p&&X<0&&(A=Vn(_+te,h[1][0])),y&&K>0?j=Bn(I+K,h[0][1]):!y&&K<0&&(j=Vn(I+Z,h[1][1])),k=Math.max(k,A),B=Math.max(B,j)}if(u){if(m){const A=Us(te/V,E,R)*V;if(k=Math.max(k,A),f){let j=0;!p&&!y||p&&!y&&g?j=Bn(I+J+te/V,f[1][1])*V:j=Vn(I+J+(p?X:-X)/V,f[0][1])*V,k=Math.max(k,j)}if(h){let j=0;!p&&!y||p&&!y&&g?j=Vn(I+te/V,h[1][1])*V:j=Bn(I+(p?X:-X)/V,h[0][1])*V,k=Math.max(k,j)}}if(v){const A=Us(Z*V,M,N)/V;if(B=Math.max(B,A),f){let j=0;!p&&!y||y&&!p&&g?j=Bn(_+Z*V+ee,f[1][0])/V:j=Vn(_+(y?K:-K)*V+ee,f[0][0])/V,B=Math.max(B,j)}if(h){let j=0;!p&&!y||y&&!p&&g?j=Vn(_+Z*V,h[1][0])/V:j=Bn(_+(y?K:-K)*V,h[0][0])/V,B=Math.max(B,j)}}}K=K+(K<0?B:-B),X=X+(X<0?k:-k),u&&(g?te>Z*V?K=(wh(p,y)?-X:X)/V:X=(wh(p,y)?-K:K)*V:m?(K=X/V,y=p):(X=K*V,p=y));const H=p?_+X:_,U=y?I+K:I;return{width:W+(p?-X:X),height:$+(y?-K:K),x:c[0]*X*(p?-1:1)+H,y:c[1]*K*(y?-1:1)+U}}const og={width:0,height:0,x:0,y:0},n1={...og,pointerX:0,pointerY:0,aspectRatio:1};function r1(t,r,o){const l=r.position.x+t.position.x,u=r.position.y+t.position.y,c=t.measured.width??0,f=t.measured.height??0,h=o[0]*c,p=o[1]*f;return[[l-h,u-p],[l+c-h,u+f-p]]}function i1({domNode:t,nodeId:r,getStoreItems:o,onChange:l,onEnd:u}){const c=kt(t);let f={controlDirection:xh("bottom-right"),boundaries:{minWidth:0,minHeight:0,maxWidth:Number.MAX_VALUE,maxHeight:Number.MAX_VALUE},resizeDirection:void 0,keepAspectRatio:!1};function h({controlPosition:y,boundaries:m,keepAspectRatio:v,resizeDirection:g,onResizeStart:w,onResize:S,onResizeEnd:M,shouldResize:N}){let E={...og},R={...n1};f={boundaries:m,resizeDirection:g,keepAspectRatio:v,controlDirection:xh(y)};let _,I=null,W=[],$,V,X,K=!1;const te=pp().on("start",Z=>{const{nodeLookup:ee,transform:J,snapGrid:k,snapToGrid:B,nodeOrigin:H,paneDomNode:U}=o();if(_=ee.get(r),!_)return;I=(U==null?void 0:U.getBoundingClientRect())??null;const{xSnapped:A,ySnapped:j}=to(Z.sourceEvent,{transform:J,snapGrid:k,snapToGrid:B,containerBounds:I});E={width:_.measured.width??0,height:_.measured.height??0,x:_.position.x??0,y:_.position.y??0},R={...E,pointerX:A,pointerY:j,aspectRatio:E.width/E.height},$=void 0,V=vr(_.extent)?_.extent:void 0,_.parentId&&(_.extent==="parent"||_.expandParent)&&($=ee.get(_.parentId)),$&&_.extent==="parent"&&(V=[[0,0],[$.measured.width,$.measured.height]]),W=[],X=void 0;for(const[F,C]of ee)if(C.parentId===r&&(W.push({id:F,position:{...C.position},extent:C.extent}),C.extent==="parent"||C.expandParent)){const z=r1(C,_,C.origin??H);X?X=[[Math.min(z[0][0],X[0][0]),Math.min(z[0][1],X[0][1])],[Math.max(z[1][0],X[1][0]),Math.max(z[1][1],X[1][1])]]:X=z}w==null||w(Z,{...E})}).on("drag",Z=>{const{transform:ee,snapGrid:J,snapToGrid:k,nodeOrigin:B}=o(),H=to(Z.sourceEvent,{transform:ee,snapGrid:J,snapToGrid:k,containerBounds:I}),U=[];if(!_)return;const{x:A,y:j,width:F,height:C}=E,z={},ne=_.origin??B,{width:ie,height:ce,x:O,y:re}=t1(R,f.controlDirection,H,f.boundaries,f.keepAspectRatio,ne,V,X),Q=ie!==F,le=ce!==C,pe=O!==A&&Q,we=re!==j&≤if(!pe&&!we&&!Q&&!le)return;if((pe||we||ne[0]===1||ne[1]===1)&&(z.x=pe?O:E.x,z.y=we?re:E.y,E.x=z.x,E.y=z.y,W.length>0)){const Ce=O-A,Me=re-j;for(const Re of W)Re.position={x:Re.position.x-Ce+ne[0]*(ie-F),y:Re.position.y-Me+ne[1]*(ce-C)},U.push(Re)}if((Q||le)&&(z.width=Q&&(!f.resizeDirection||f.resizeDirection==="horizontal")?ie:E.width,z.height=le&&(!f.resizeDirection||f.resizeDirection==="vertical")?ce:E.height,E.width=z.width,E.height=z.height),$&&_.expandParent){const Ce=ne[0]*(z.width??0);z.x&&z.x{K&&(M==null||M(Z,{...E}),u==null||u({...E}),K=!1)});c.call(te)}function p(){c.on(".drag",null)}return{update:h,destroy:p}}var Na={exports:{}},Ca={},Ma={exports:{}},Pa={};/** + * @license React + * use-sync-external-store-shim.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sh;function o1(){if(Sh)return Pa;Sh=1;var t=fo();function r(v,g){return v===g&&(v!==0||1/v===1/g)||v!==v&&g!==g}var o=typeof Object.is=="function"?Object.is:r,l=t.useState,u=t.useEffect,c=t.useLayoutEffect,f=t.useDebugValue;function h(v,g){var w=g(),S=l({inst:{value:w,getSnapshot:g}}),M=S[0].inst,N=S[1];return c(function(){M.value=w,M.getSnapshot=g,p(M)&&N({inst:M})},[v,w,g]),u(function(){return p(M)&&N({inst:M}),v(function(){p(M)&&N({inst:M})})},[v]),f(w),w}function p(v){var g=v.getSnapshot;v=v.value;try{var w=g();return!o(v,w)}catch{return!0}}function y(v,g){return g()}var m=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?y:h;return Pa.useSyncExternalStore=t.useSyncExternalStore!==void 0?t.useSyncExternalStore:m,Pa}var _h;function s1(){return _h||(_h=1,Ma.exports=o1()),Ma.exports}/** + * @license React + * use-sync-external-store-shim/with-selector.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Eh;function l1(){if(Eh)return Ca;Eh=1;var t=fo(),r=s1();function o(y,m){return y===m&&(y!==0||1/y===1/m)||y!==y&&m!==m}var l=typeof Object.is=="function"?Object.is:o,u=r.useSyncExternalStore,c=t.useRef,f=t.useEffect,h=t.useMemo,p=t.useDebugValue;return Ca.useSyncExternalStoreWithSelector=function(y,m,v,g,w){var S=c(null);if(S.current===null){var M={hasValue:!1,value:null};S.current=M}else M=S.current;S=h(function(){function E($){if(!R){if(R=!0,_=$,$=g($),w!==void 0&&M.hasValue){var V=M.value;if(w(V,$))return I=V}return I=$}if(V=I,l(_,$))return V;var X=g($);return w!==void 0&&w(V,X)?(_=$,V):(_=$,I=X)}var R=!1,_,I,W=v===void 0?null:v;return[function(){return E(m())},W===null?void 0:function(){return E(W())}]},[m,v,g,w]);var N=u(y,S[0],S[1]);return f(function(){M.hasValue=!0,M.value=N},[N]),p(N),N},Ca}var kh;function u1(){return kh||(kh=1,Na.exports=l1()),Na.exports}var a1=u1();const c1=Zh(a1),f1={},Nh=t=>{let r;const o=new Set,l=(m,v)=>{const g=typeof m=="function"?m(r):m;if(!Object.is(g,r)){const w=r;r=v??(typeof g!="object"||g===null)?g:Object.assign({},r,g),o.forEach(S=>S(r,w))}},u=()=>r,p={setState:l,getState:u,getInitialState:()=>y,subscribe:m=>(o.add(m),()=>o.delete(m)),destroy:()=>{(f1?"production":void 0)!=="production"&&console.warn("[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected."),o.clear()}},y=r=t(l,u,p);return p},d1=t=>t?Nh(t):Nh,{useDebugValue:h1}=E0,{useSyncExternalStoreWithSelector:p1}=c1,g1=t=>t;function sg(t,r=g1,o){const l=p1(t.subscribe,t.getState,t.getServerState||t.getInitialState,r,o);return h1(l),l}const Ch=(t,r)=>{const o=d1(t),l=(u,c=r)=>sg(o,u,c);return Object.assign(l,o),l},m1=(t,r)=>t?Ch(t,r):Ch;function Ve(t,r){if(Object.is(t,r))return!0;if(typeof t!="object"||t===null||typeof r!="object"||r===null)return!1;if(t instanceof Map&&r instanceof Map){if(t.size!==r.size)return!1;for(const[l,u]of t)if(!Object.is(u,r.get(l)))return!1;return!0}if(t instanceof Set&&r instanceof Set){if(t.size!==r.size)return!1;for(const l of t)if(!r.has(l))return!1;return!0}const o=Object.keys(t);if(o.length!==Object.keys(r).length)return!1;for(const l of o)if(!Object.prototype.hasOwnProperty.call(r,l)||!Object.is(t[l],r[l]))return!1;return!0}Jh();const yl=G.createContext(null),y1=yl.Provider,lg=Yt.error001("react");function Ie(t,r){const o=G.useContext(yl);if(o===null)throw new Error(lg);return sg(o,t,r)}function $e(){const t=G.useContext(yl);if(t===null)throw new Error(lg);return G.useMemo(()=>({getState:t.getState,setState:t.setState,subscribe:t.subscribe}),[t])}const Mh={display:"none"},v1={position:"absolute",width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0px, 0px, 0px, 0px)",clipPath:"inset(100%)"},ug="react-flow__node-desc",ag="react-flow__edge-desc",x1="react-flow__aria-live",w1=t=>t.ariaLiveMessage,S1=t=>t.ariaLabelConfig;function _1({rfId:t}){const r=Ie(w1);return P.jsx("div",{id:`${x1}-${t}`,"aria-live":"assertive","aria-atomic":"true",style:v1,children:r})}function E1({rfId:t,disableKeyboardA11y:r}){const o=Ie(S1);return P.jsxs(P.Fragment,{children:[P.jsx("div",{id:`${ug}-${t}`,style:Mh,children:r?o["node.a11yDescription.default"]:o["node.a11yDescription.keyboardDisabled"]}),P.jsx("div",{id:`${ag}-${t}`,style:Mh,children:o["edge.a11yDescription.default"]}),!r&&P.jsx(_1,{rfId:t})]})}const vl=G.forwardRef(({position:t="top-left",children:r,className:o,style:l,...u},c)=>{const f=`${t}`.split("-");return P.jsx("div",{className:be(["react-flow__panel",o,...f]),style:l,ref:c,...u,children:r})});vl.displayName="Panel";const Ph="https://reactflow.dev?utm_source=attribution";function k1({proOptions:t,position:r="bottom-right"}){return t!=null&&t.hideAttribution?null:P.jsx(vl,{position:r,className:"react-flow__attribution","data-message":`Please only hide this attribution when you are subscribed to React Flow Pro: ${Ph}`,children:P.jsx("a",{href:Ph,target:"_blank",rel:"noopener noreferrer","aria-label":"React Flow attribution",children:"React Flow"})})}const N1=t=>{const r=[],o=[];for(const[,l]of t.nodeLookup)l.selected&&r.push(l.internals.userNode);for(const[,l]of t.edgeLookup)l.selected&&o.push(l);return{selectedNodes:r,selectedEdges:o}},Ws=t=>t.id;function C1(t,r){return Ve(t.selectedNodes.map(Ws),r.selectedNodes.map(Ws))&&Ve(t.selectedEdges.map(Ws),r.selectedEdges.map(Ws))}function M1({onSelectionChange:t}){const r=$e(),{selectedNodes:o,selectedEdges:l}=Ie(N1,C1);return G.useEffect(()=>{const u={nodes:o,edges:l};t==null||t(u),r.getState().onSelectionChangeHandlers.forEach(c=>c(u))},[o,l,t]),null}const P1=t=>!!t.onSelectionChangeHandlers;function I1({onSelectionChange:t}){const r=Ie(P1);return t||r?P.jsx(M1,{onSelectionChange:t}):null}const cg=[0,0],T1={x:0,y:0,zoom:1},z1=["nodes","edges","defaultNodes","defaultEdges","onConnect","onConnectStart","onConnectEnd","onClickConnectStart","onClickConnectEnd","nodesDraggable","autoPanOnNodeFocus","nodesConnectable","nodesFocusable","edgesFocusable","edgesReconnectable","elevateNodesOnSelect","elevateEdgesOnSelect","minZoom","maxZoom","nodeExtent","onNodesChange","onEdgesChange","elementsSelectable","connectionMode","snapGrid","snapToGrid","translateExtent","connectOnClick","defaultEdgeOptions","fitView","fitViewOptions","onNodesDelete","onEdgesDelete","onDelete","onNodeDrag","onNodeDragStart","onNodeDragStop","onSelectionDrag","onSelectionDragStart","onSelectionDragStop","onMoveStart","onMove","onMoveEnd","noPanClassName","nodeOrigin","autoPanOnConnect","autoPanOnNodeDrag","onError","connectionRadius","isValidConnection","selectNodesOnDrag","nodeDragThreshold","connectionDragThreshold","onBeforeDelete","debug","autoPanSpeed","ariaLabelConfig","zIndexMode"],Ih=[...z1,"rfId"],R1=t=>({setNodes:t.setNodes,setEdges:t.setEdges,setMinZoom:t.setMinZoom,setMaxZoom:t.setMaxZoom,setTranslateExtent:t.setTranslateExtent,setNodeExtent:t.setNodeExtent,reset:t.reset,setDefaultNodesAndEdges:t.setDefaultNodesAndEdges}),Th={translateExtent:so,nodeOrigin:cg,minZoom:.5,maxZoom:2,elementsSelectable:!0,noPanClassName:"nopan",rfId:"1"};function L1(t){const{setNodes:r,setEdges:o,setMinZoom:l,setMaxZoom:u,setTranslateExtent:c,setNodeExtent:f,reset:h,setDefaultNodesAndEdges:p}=Ie(R1,Ve),y=$e();G.useEffect(()=>(p(t.defaultNodes,t.defaultEdges),()=>{m.current=Th,h()}),[]);const m=G.useRef(Th);return G.useEffect(()=>{for(const v of Ih){const g=t[v],w=m.current[v];g!==w&&(typeof t[v]>"u"||(v==="nodes"?r(g):v==="edges"?o(g):v==="minZoom"?l(g):v==="maxZoom"?u(g):v==="translateExtent"?c(g):v==="nodeExtent"?f(g):v==="ariaLabelConfig"?y.setState({ariaLabelConfig:yw(g)}):v==="fitView"?y.setState({fitViewQueued:g}):v==="fitViewOptions"?y.setState({fitViewOptions:g}):y.setState({[v]:g})))}m.current=t},Ih.map(v=>t[v])),null}function zh(){return typeof window>"u"||!window.matchMedia?null:window.matchMedia("(prefers-color-scheme: dark)")}function j1(t){var l;const[r,o]=G.useState(t==="system"?null:t);return G.useEffect(()=>{if(t!=="system"){o(t);return}const u=zh(),c=()=>o(u!=null&&u.matches?"dark":"light");return c(),u==null||u.addEventListener("change",c),()=>{u==null||u.removeEventListener("change",c)}},[t]),r!==null?r:(l=zh())!=null&&l.matches?"dark":"light"}const Rh=typeof document<"u"?document:null;function co(t=null,r={target:Rh,actInsideInputWithModifier:!0}){const[o,l]=G.useState(!1),u=G.useRef(!1),c=G.useRef(new Set([])),[f,h]=G.useMemo(()=>{if(t!==null){const y=(Array.isArray(t)?t:[t]).filter(v=>typeof v=="string").map(v=>v.replace(/\+/g,` +`).replace(` + +`,` ++`).split(` +`)),m=y.reduce((v,g)=>v.concat(...g),[]);return[y,m]}return[[],[]]},[t]);return G.useEffect(()=>{const p=(r==null?void 0:r.target)??Rh,y=(r==null?void 0:r.actInsideInputWithModifier)??!0;if(t!==null){const m=w=>{var N,E;if(u.current=w.ctrlKey||w.metaKey||w.shiftKey||w.altKey,(!u.current||u.current&&!y)&&Up(w))return!1;const M=jh(w.code,h);if(c.current.add(w[M]),Lh(f,c.current,!1)){const R=((E=(N=w.composedPath)==null?void 0:N.call(w))==null?void 0:E[0])||w.target,_=(R==null?void 0:R.nodeName)==="BUTTON"||(R==null?void 0:R.nodeName)==="A";r.preventDefault!==!1&&(u.current||!_)&&w.preventDefault(),l(!0)}},v=w=>{const S=jh(w.code,h);Lh(f,c.current,!0)?(l(!1),c.current.clear()):c.current.delete(w[S]),w.key==="Meta"&&c.current.clear(),u.current=!1},g=()=>{c.current.clear(),l(!1)};return p==null||p.addEventListener("keydown",m),p==null||p.addEventListener("keyup",v),window.addEventListener("blur",g),window.addEventListener("contextmenu",g),()=>{p==null||p.removeEventListener("keydown",m),p==null||p.removeEventListener("keyup",v),window.removeEventListener("blur",g),window.removeEventListener("contextmenu",g)}}},[t,l]),o}function Lh(t,r,o){return t.filter(l=>o||l.length===r.size).some(l=>l.every(u=>r.has(u)))}function jh(t,r){return r.includes(t)?"code":"key"}const A1=()=>{const t=$e();return G.useMemo(()=>({zoomIn:async r=>{const{panZoom:o}=t.getState();return o?o.scaleBy(1.2,r):!1},zoomOut:async r=>{const{panZoom:o}=t.getState();return o?o.scaleBy(1/1.2,r):!1},zoomTo:async(r,o)=>{const{panZoom:l}=t.getState();return l?l.scaleTo(r,o):!1},getZoom:()=>t.getState().transform[2],setViewport:async(r,o)=>{const{transform:[l,u,c],panZoom:f}=t.getState();return f?(await f.setViewport({x:r.x??l,y:r.y??u,zoom:r.zoom??c},o),!0):!1},getViewport:()=>{const[r,o,l]=t.getState().transform;return{x:r,y:o,zoom:l}},setCenter:async(r,o,l)=>t.getState().setCenter(r,o,l),fitBounds:async(r,o)=>{const{width:l,height:u,minZoom:c,maxZoom:f,panZoom:h}=t.getState(),p=nc(r,l,u,c,f,(o==null?void 0:o.padding)??.1);return h?(await h.setViewport(p,{duration:o==null?void 0:o.duration,ease:o==null?void 0:o.ease,interpolate:o==null?void 0:o.interpolate}),!0):!1},screenToFlowPosition:(r,o={})=>{const{transform:l,snapGrid:u,snapToGrid:c,domNode:f}=t.getState();if(!f)return r;const{x:h,y:p}=f.getBoundingClientRect(),y={x:r.x-h,y:r.y-p},m=o.snapGrid??u,v=o.snapToGrid??c;return vo(y,l,v,m)},flowToScreenPosition:r=>{const{transform:o,domNode:l}=t.getState();if(!l)return r;const{x:u,y:c}=l.getBoundingClientRect(),f=ii(r,o);return{x:f.x+u,y:f.y+c}}}),[])};function fg(t,r){const o=[],l=new Map,u=[];for(const c of t)if(c.type==="add"){u.push(c);continue}else if(c.type==="remove"||c.type==="replace")l.set(c.id,[c]);else{const f=l.get(c.id);f?f.push(c):l.set(c.id,[c])}for(const c of r){const f=l.get(c.id);if(!f){o.push(c);continue}if(f[0].type==="remove")continue;if(f[0].type==="replace"){o.push({...f[0].item});continue}const h={...c};for(const p of f)$1(p,h);o.push(h)}return u.length&&u.forEach(c=>{c.index!==void 0?o.splice(c.index,0,{...c.item}):o.push({...c.item})}),o}function $1(t,r){switch(t.type){case"select":{r.selected=t.selected;break}case"position":{typeof t.position<"u"&&(r.position=t.position),typeof t.dragging<"u"&&(r.dragging=t.dragging);break}case"dimensions":{typeof t.dimensions<"u"&&(r.measured={...t.dimensions},t.setAttributes&&((t.setAttributes===!0||t.setAttributes==="width")&&(r.width=t.dimensions.width),(t.setAttributes===!0||t.setAttributes==="height")&&(r.height=t.dimensions.height))),typeof t.resizing=="boolean"&&(r.resizing=t.resizing);break}}}function D1(t,r){return fg(t,r)}function O1(t,r){return fg(t,r)}function fr(t,r){return{id:t,type:"select",selected:r}}function qr(t,r=new Set,o=!1){const l=[];for(const[u,c]of t){const f=r.has(u);!(c.selected===void 0&&!f)&&c.selected!==f&&(o&&(c.selected=f),l.push(fr(c.id,f)))}return l}function Ah({items:t=[],lookup:r}){var u;const o=[],l=new Map(t.map(c=>[c.id,c]));for(const[c,f]of t.entries()){const h=r.get(f.id),p=((u=h==null?void 0:h.internals)==null?void 0:u.userNode)??h;p!==void 0&&p!==f&&o.push({id:f.id,item:f,type:"replace"}),p===void 0&&o.push({item:f,type:"add",index:c})}for(const[c]of r)l.get(c)===void 0&&o.push({id:c,type:"remove"});return o}function $h(t){return{id:t.id,type:"remove"}}const F1=Fp();function H1(t,r,o={}){return Ew(t,r,{...o,onError:o.onError??F1})}const Dh=t=>uw(t),V1=t=>jp(t);function dg(t){return G.forwardRef(t)}const hg=typeof window<"u"?G.useLayoutEffect:G.useEffect;function Oh(t){const[r,o]=G.useState(BigInt(0)),[l]=G.useState(()=>B1(()=>o(u=>u+BigInt(1))));return hg(()=>{const u=l.get();u.length&&(t(u),l.reset())},[r]),l}function B1(t){let r=[];return{get:()=>r,reset:()=>{r=[]},push:o=>{r.push(o),t()}}}const pg=G.createContext(null);function U1({children:t}){const r=$e(),o=G.useCallback(h=>{const{nodes:p=[],setNodes:y,hasDefaultNodes:m,onNodesChange:v,nodeLookup:g,fitViewQueued:w,onNodesChangeMiddlewareMap:S}=r.getState();let M=p;for(const E of h)M=typeof E=="function"?E(M):E;let N=Ah({items:M,lookup:g});for(const E of S.values())N=E(N);m&&y(M),N.length>0?v==null||v(N):w&&window.requestAnimationFrame(()=>{const{fitViewQueued:E,nodes:R,setNodes:_}=r.getState();E&&_(R)})},[]),l=Oh(o),u=G.useCallback(h=>{const{edges:p=[],setEdges:y,hasDefaultEdges:m,onEdgesChange:v,edgeLookup:g}=r.getState();let w=p;for(const S of h)w=typeof S=="function"?S(w):S;m?y(w):v&&v(Ah({items:w,lookup:g}))},[]),c=Oh(u),f=G.useMemo(()=>({nodeQueue:l,edgeQueue:c}),[]);return P.jsx(pg.Provider,{value:f,children:t})}function W1(){const t=G.useContext(pg);if(!t)throw new Error("useBatchContext must be used within a BatchProvider");return t}const b1=t=>!!t.panZoom;function ac(){const t=A1(),r=$e(),o=W1(),l=Ie(b1),u=G.useMemo(()=>{const c=v=>r.getState().nodeLookup.get(v),f=v=>{o.nodeQueue.push(v)},h=v=>{o.edgeQueue.push(v)},p=v=>{var E,R;const{nodeLookup:g,nodeOrigin:w}=r.getState(),S=Dh(v)?v:g.get(v.id),M=S.parentId?Vp(S.position,S.measured,S.parentId,g,w):S.position,N={...S,position:M,width:((E=S.measured)==null?void 0:E.width)??S.width,height:((R=S.measured)==null?void 0:R.height)??S.height};return uo(N)},y=(v,g,w={replace:!1})=>{f(S=>S.map(M=>{if(M.id===v){const N=typeof g=="function"?g(M):g;return w.replace&&Dh(N)?N:{...M,...N}}return M}))},m=(v,g,w={replace:!1})=>{h(S=>S.map(M=>{if(M.id===v){const N=typeof g=="function"?g(M):g;return w.replace&&V1(N)?N:{...M,...N}}return M}))};return{getNodes:()=>r.getState().nodes.map(v=>({...v})),getNode:v=>{var g;return(g=c(v))==null?void 0:g.internals.userNode},getInternalNode:c,getEdges:()=>{const{edges:v=[]}=r.getState();return v.map(g=>({...g}))},getEdge:v=>r.getState().edgeLookup.get(v),setNodes:f,setEdges:h,addNodes:v=>{const g=Array.isArray(v)?v:[v];o.nodeQueue.push(w=>[...w,...g])},addEdges:v=>{const g=Array.isArray(v)?v:[v];o.edgeQueue.push(w=>[...w,...g])},toObject:()=>{const{nodes:v=[],edges:g=[],transform:w}=r.getState(),[S,M,N]=w;return{nodes:v.map(E=>({...E})),edges:g.map(E=>({...E})),viewport:{x:S,y:M,zoom:N}}},deleteElements:async({nodes:v=[],edges:g=[]})=>{const{nodes:w,edges:S,onNodesDelete:M,onEdgesDelete:N,triggerNodeChanges:E,triggerEdgeChanges:R,onDelete:_,onBeforeDelete:I}=r.getState(),{nodes:W,edges:$}=await hw({nodesToRemove:v,edgesToRemove:g,nodes:w,edges:S,onBeforeDelete:I}),V=$.length>0,X=W.length>0;if(V){const K=$.map($h);N==null||N($),R(K)}if(X){const K=W.map($h);M==null||M(W),E(K)}return(X||V)&&(_==null||_({nodes:W,edges:$})),{deletedNodes:W,deletedEdges:$}},getIntersectingNodes:(v,g=!0,w)=>{const S=uh(v),M=S?v:p(v),N=w!==void 0;return M?(w||r.getState().nodes).filter(E=>{const R=r.getState().nodeLookup.get(E.id);if(R&&!S&&(E.id===v.id||!R.internals.positionAbsolute))return!1;const _=uo(N?E:R),I=sl(_,M);return g&&I>0||I>=_.width*_.height||I>=M.width*M.height}):[]},isNodeIntersecting:(v,g,w=!0)=>{const M=uh(v)?v:p(v);if(!M)return!1;const N=sl(M,g);return w&&N>0||N>=g.width*g.height||N>=M.width*M.height},updateNode:y,updateNodeData:(v,g,w={replace:!1})=>{y(v,S=>{const M=typeof g=="function"?g(S):g;return w.replace?{...S,data:M}:{...S,data:{...S.data,...M}}},w)},updateEdge:m,updateEdgeData:(v,g,w={replace:!1})=>{m(v,S=>{const M=typeof g=="function"?g(S):g;return w.replace?{...S,data:M}:{...S,data:{...S.data,...M}}},w)},getNodesBounds:v=>{const{nodeLookup:g,nodeOrigin:w}=r.getState();return aw(v,{nodeLookup:g,nodeOrigin:w})},getHandleConnections:({type:v,id:g,nodeId:w})=>{var S;return Array.from(((S=r.getState().connectionLookup.get(`${w}-${v}${g?`-${g}`:""}`))==null?void 0:S.values())??[])},getNodeConnections:({type:v,handleId:g,nodeId:w})=>{var S;return Array.from(((S=r.getState().connectionLookup.get(`${w}${v?g?`-${v}-${g}`:`-${v}`:""}`))==null?void 0:S.values())??[])},fitView:async v=>{const g=r.getState().fitViewResolver??mw();return r.setState({fitViewQueued:!0,fitViewOptions:v,fitViewResolver:g}),o.nodeQueue.push(w=>[...w]),g.promise}}},[]);return G.useMemo(()=>({...u,...t,viewportInitialized:l}),[l])}const Fh=t=>t.selected,Y1=typeof window<"u"?window:void 0;function X1({deleteKeyCode:t,multiSelectionKeyCode:r}){const o=$e(),{deleteElements:l}=ac(),u=co(t,{actInsideInputWithModifier:!1}),c=co(r,{target:Y1});G.useEffect(()=>{if(u){const{edges:f,nodes:h}=o.getState();l({nodes:h.filter(Fh),edges:f.filter(Fh)}),o.setState({nodesSelectionActive:!1})}},[u]),G.useEffect(()=>{o.setState({multiSelectionActive:c})},[c])}function Q1(t){const r=$e();G.useEffect(()=>{const o=()=>{var u,c,f,h;if(!t.current||!(((c=(u=t.current).checkVisibility)==null?void 0:c.call(u))??!0))return!1;const l=rc(t.current);(l.height===0||l.width===0)&&((h=(f=r.getState()).onError)==null||h.call(f,"004",Yt.error004())),r.setState({width:l.width||500,height:l.height||500})};if(t.current){o(),window.addEventListener("resize",o);const l=new ResizeObserver(()=>o());return l.observe(t.current),()=>{window.removeEventListener("resize",o),l&&t.current&&l.unobserve(t.current)}}},[])}const xl={position:"absolute",width:"100%",height:"100%",top:0,left:0},G1=t=>({userSelectionActive:t.userSelectionActive,lib:t.lib,connectionInProgress:t.connection.inProgress});function K1({onPaneContextMenu:t,zoomOnScroll:r=!0,zoomOnPinch:o=!0,panOnScroll:l=!1,panActivationKeyPressed:u,panOnScrollSpeed:c=.5,panOnScrollMode:f=pr.Free,zoomOnDoubleClick:h=!0,panOnDrag:p=!0,defaultViewport:y,translateExtent:m,minZoom:v,maxZoom:g,zoomActivationKeyCode:w,preventScrolling:S=!0,children:M,noWheelClassName:N,noPanClassName:E,onViewportChange:R,isControlledViewport:_,paneClickDistance:I,selectionOnDrag:W}){const $=$e(),V=G.useRef(null),{userSelectionActive:X,lib:K,connectionInProgress:te}=Ie(G1,Ve),Z=co(w),ee=G.useRef();Q1(V);const J=G.useCallback(k=>{R==null||R({x:k[0],y:k[1],zoom:k[2]}),_||$.setState({transform:k})},[R,_]);return G.useEffect(()=>{if(V.current){ee.current=Jw({domNode:V.current,minZoom:v,maxZoom:g,translateExtent:m,viewport:y,onDraggingChange:U=>$.setState(A=>A.paneDragging===U?A:{paneDragging:U}),onPanZoomStart:(U,A)=>{const{onViewportChangeStart:j,onMoveStart:F}=$.getState();F==null||F(U,A),j==null||j(A)},onPanZoom:(U,A)=>{const{onViewportChange:j,onMove:F}=$.getState();F==null||F(U,A),j==null||j(A)},onPanZoomEnd:(U,A)=>{const{onViewportChangeEnd:j,onMoveEnd:F}=$.getState();F==null||F(U,A),j==null||j(A)}});const{x:k,y:B,zoom:H}=ee.current.getViewport();return $.setState({panZoom:ee.current,transform:[k,B,H],domNode:V.current.closest(".react-flow")}),()=>{var U;(U=ee.current)==null||U.destroy()}}},[]),G.useEffect(()=>{var k;(k=ee.current)==null||k.update({onPaneContextMenu:t,zoomOnScroll:r,zoomOnPinch:o,panOnScroll:l,panActivationKeyPressed:u,panOnScrollSpeed:c,panOnScrollMode:f,zoomOnDoubleClick:h,panOnDrag:p,zoomActivationKeyPressed:Z,preventScrolling:S,noPanClassName:E,userSelectionActive:X,noWheelClassName:N,lib:K,onTransformChange:J,connectionInProgress:te,selectionOnDrag:W,paneClickDistance:I})},[t,r,o,l,u,c,f,h,p,Z,S,E,X,N,K,J,te,W,I]),P.jsx("div",{className:"react-flow__renderer",ref:V,style:xl,children:M})}const q1=t=>({userSelectionActive:t.userSelectionActive,userSelectionRect:t.userSelectionRect});function Z1(){const{userSelectionActive:t,userSelectionRect:r}=Ie(q1,Ve);return t&&r?P.jsx("div",{className:"react-flow__selection react-flow__container",style:{width:r.width,height:r.height,transform:`translate(${r.x}px, ${r.y}px)`}}):null}const Ia=(t,r)=>o=>{o.target===r.current&&(t==null||t(o))},J1=t=>({userSelectionActive:t.userSelectionActive,elementsSelectable:t.elementsSelectable,dragging:t.paneDragging,panBy:t.panBy,autoPanSpeed:t.autoPanSpeed});function eS({isSelecting:t,selectionKeyPressed:r,selectionMode:o=lo.Full,panOnDrag:l,autoPanOnSelection:u,paneClickDistance:c,selectionOnDrag:f,onSelectionStart:h,onSelectionEnd:p,onPaneClick:y,onPaneContextMenu:m,onPaneScroll:v,onPaneMouseEnter:g,onPaneMouseMove:w,onPaneMouseLeave:S,children:M}){const N=G.useRef(0),E=$e(),{userSelectionActive:R,elementsSelectable:_,dragging:I,panBy:W,autoPanSpeed:$}=Ie(J1,Ve),V=_&&(t||R),X=G.useRef(null),K=G.useRef(),te=G.useRef(new Set),Z=G.useRef(new Set),ee=G.useRef(!1),J=G.useRef(!1),k=G.useRef({x:0,y:0}),B=G.useRef(!1),H=Q=>{if(J.current||ee.current||E.getState().connection.inProgress){J.current=!1,ee.current=!1;return}y==null||y(Q),E.getState().resetSelectedElements(),E.setState({nodesSelectionActive:!1})},U=Q=>{if(Array.isArray(l)&&(l!=null&&l.includes(2))){Q.preventDefault();return}m==null||m(Q)},A=v?Q=>v(Q):void 0,j=Q=>{J.current&&(Q.stopPropagation(),J.current=!1)},F=Q=>{var Re,ot;if(Q.pointerType==="touch"&&l!==!1&&!r)return;const{domNode:le,transform:pe}=E.getState();if(K.current=le==null?void 0:le.getBoundingClientRect(),!K.current)return;const we=Q.target===X.current;if(!we&&!!Q.target.closest(".nokey")||!t||!(f&&we||r)||Q.button!==0||!Q.isPrimary)return;(ot=(Re=Q.target)==null?void 0:Re.setPointerCapture)==null||ot.call(Re,Q.pointerId),J.current=!1;const{x:ke,y:Ce}=bt(Q.nativeEvent,K.current),Me=vo({x:ke,y:Ce},pe);E.setState({userSelectionRect:{width:0,height:0,startX:Me.x,startY:Me.y,x:ke,y:Ce}}),we||(Q.stopPropagation(),Q.preventDefault())};function C(Q,le){const{userSelectionRect:pe}=E.getState();if(!pe)return;const{transform:we,nodeLookup:ve,edgeLookup:me,connectionLookup:ke,triggerNodeChanges:Ce,triggerEdgeChanges:Me,defaultEdgeOptions:Re}=E.getState(),ot={x:pe.startX,y:pe.startY},{x:ut,y:Je}=ii(ot,we),et={startX:ot.x,startY:ot.y,x:Qat.id)),Z.current=new Set;const Kt=(Re==null?void 0:Re.selectable)??!0;for(const at of te.current){const Ct=ke.get(at);if(Ct)for(const{edgeId:yt}of Ct.values()){const Mt=me.get(yt);Mt&&(Mt.selectable??Kt)&&Z.current.add(yt)}}if(!ah(sn,te.current)){const at=qr(ve,te.current,!0);Ce(at)}if(!ah(Gt,Z.current)){const at=qr(me,Z.current);Me(at)}E.setState({userSelectionRect:et,userSelectionActive:!0,nodesSelectionActive:!1})}function z(){if(!u||!K.current)return;const[Q,le]=tc(k.current,K.current,$);W({x:Q,y:le}).then(pe=>{if(!J.current||!pe){N.current=requestAnimationFrame(z);return}const{x:we,y:ve}=k.current;C(we,ve),N.current=requestAnimationFrame(z)})}const ne=()=>{cancelAnimationFrame(N.current),N.current=0,B.current=!1};G.useEffect(()=>()=>ne(),[]);const ie=Q=>{const{userSelectionRect:le,transform:pe,resetSelectedElements:we}=E.getState();if(!K.current||!le)return;const{x:ve,y:me}=bt(Q.nativeEvent,K.current);k.current={x:ve,y:me};const ke=ii({x:le.startX,y:le.startY},pe);if(!J.current){const Ce=r?0:c;if(Math.hypot(ve-ke.x,me-ke.y)<=Ce)return;we(),h==null||h(Q)}J.current=!0,B.current||(z(),B.current=!0),C(ve,me)},ce=Q=>{var le,pe;if(!V){Q.target===X.current&&E.getState().connection.inProgress&&(ee.current=!0);return}Q.button===0&&((pe=(le=Q.target)==null?void 0:le.releasePointerCapture)==null||pe.call(le,Q.pointerId),!R&&Q.target===X.current&&E.getState().userSelectionRect&&(H==null||H(Q)),E.setState({userSelectionActive:!1,userSelectionRect:null}),J.current&&(p==null||p(Q),E.setState({nodesSelectionActive:te.current.size>0})),ne())},O=Q=>{var le,pe;(pe=(le=Q.target)==null?void 0:le.releasePointerCapture)==null||pe.call(le,Q.pointerId),ne()},re=l===!0||Array.isArray(l)&&l.includes(0);return P.jsxs("div",{className:be(["react-flow__pane",{draggable:re,dragging:I,selection:t}]),onClick:V?void 0:Ia(H,X),onContextMenu:Ia(U,X),onWheel:Ia(A,X),onPointerEnter:V?void 0:g,onPointerMove:V?ie:w,onPointerUp:ce,onPointerCancel:V?O:void 0,onPointerDownCapture:V?F:void 0,onClickCapture:V?j:void 0,onPointerLeave:S,ref:X,style:xl,children:[M,P.jsx(Z1,{})]})}function ba({id:t,store:r,unselect:o=!1,nodeRef:l}){const{addSelectedNodes:u,unselectNodesAndEdges:c,multiSelectionActive:f,nodeLookup:h,onError:p}=r.getState(),y=h.get(t);if(!y){p==null||p("012",Yt.error012(t));return}r.setState({nodesSelectionActive:!1}),y.selected?(o||y.selected&&f)&&(c({nodes:[y],edges:[]}),requestAnimationFrame(()=>{var m;return(m=l==null?void 0:l.current)==null?void 0:m.blur()})):u([t])}function gg({nodeRef:t,disabled:r=!1,noDragClassName:o,handleSelector:l,nodeId:u,isSelectable:c,nodeClickDistance:f}){const h=$e(),[p,y]=G.useState(!1),m=G.useRef();return G.useEffect(()=>{if(!r)return m.current=Fw({getStoreItems:()=>h.getState(),onNodeMouseDown:v=>{ba({id:v,store:h,nodeRef:t})},onDragStart:()=>{y(!0)},onDragStop:()=>{y(!1)}}),()=>{var v;(v=m.current)==null||v.destroy(),m.current=void 0}},[r,h,t]),G.useEffect(()=>{r||!t.current||!m.current||m.current.update({noDragClassName:o,handleSelector:l,domNode:t.current,isSelectable:c,nodeId:u,nodeClickDistance:f})},[o,l,r,c,t,u,f]),p}const tS=t=>r=>r.selected&&(r.draggable||t&&typeof r.draggable>"u");function mg(){const t=$e();return G.useCallback(o=>{const{nodeExtent:l,snapToGrid:u,snapGrid:c,nodesDraggable:f,onError:h,updateNodePositions:p,nodeLookup:y,nodeOrigin:m}=t.getState(),v=new Map,g=tS(f),w=u?c[0]:5,S=u?c[1]:5,M=o.direction.x*w*o.factor,N=o.direction.y*S*o.factor;for(const[,E]of y){if(!g(E))continue;let R={x:E.internals.positionAbsolute.x+M,y:E.internals.positionAbsolute.y+N};u&&(R=yo(R,c));const{position:_,positionAbsolute:I}=Ap({nodeId:E.id,nextPosition:R,nodeLookup:y,nodeExtent:l,nodeOrigin:m,onError:h});E.position=_,E.internals.positionAbsolute=I,v.set(E.id,E)}p(v)},[])}const cc=G.createContext(null),nS=cc.Provider;cc.Consumer;const yg=()=>G.useContext(cc),rS=t=>({connectOnClick:t.connectOnClick,noPanClassName:t.noPanClassName,rfId:t.rfId}),vg=G.createContext(null);function iS({children:t}){const r=Ie(rS,Ve);return P.jsx(vg.Provider,{value:r,children:t})}function oS(){const t=G.useContext(vg);if(!t)throw new Error("useHandleConfig must be used within a HandleConfigProvider");return t}const sS={connectingFrom:!1,connectingTo:!1,clickConnecting:!1,isPossibleEndHandle:!0,connectionInProcess:!1,clickConnectionInProcess:!1,valid:!1},lS=(t,r,o)=>l=>{const{connectionClickStartHandle:u,connectionMode:c,connection:f}=l,{fromHandle:h,toHandle:p,isValid:y}=f;if(!h&&!u)return sS;const m=(p==null?void 0:p.nodeId)===t&&(p==null?void 0:p.id)===r&&(p==null?void 0:p.type)===o;return{connectingFrom:(h==null?void 0:h.nodeId)===t&&(h==null?void 0:h.id)===r&&(h==null?void 0:h.type)===o,connectingTo:m,clickConnecting:(u==null?void 0:u.nodeId)===t&&(u==null?void 0:u.id)===r&&(u==null?void 0:u.type)===o,isPossibleEndHandle:c===ni.Strict?(h==null?void 0:h.type)!==o:t!==(h==null?void 0:h.nodeId)||r!==(h==null?void 0:h.id),connectionInProcess:!!h,clickConnectionInProcess:!!u,valid:m&&y}};function uS({type:t="source",position:r=xe.Top,isValidConnection:o,isConnectable:l=!0,isConnectableStart:u=!0,isConnectableEnd:c=!0,id:f,onConnect:h,children:p,className:y,onMouseDown:m,onTouchStart:v,...g},w){var B,H;const S=f||null,M=t==="target",N=$e(),E=yg(),{connectOnClick:R,noPanClassName:_,rfId:I}=oS(),{connectingFrom:W,connectingTo:$,clickConnecting:V,isPossibleEndHandle:X,connectionInProcess:K,clickConnectionInProcess:te,valid:Z}=Ie(lS(E,S,t),Ve);E||(H=(B=N.getState()).onError)==null||H.call(B,"010",Yt.error010());const ee=U=>{const{defaultEdgeOptions:A,onConnect:j,hasDefaultEdges:F}=N.getState(),C={...A,...U};if(F){const{edges:z,setEdges:ne,onError:ie}=N.getState();ne(H1(C,z,{onError:ie}))}j==null||j(C),h==null||h(C)},J=U=>{if(!E)return;const A=Wp(U.nativeEvent);if(u&&(A&&U.button===0||!A)){const j=N.getState();Wa.onPointerDown(U.nativeEvent,{handleDomNode:U.currentTarget,autoPanOnConnect:j.autoPanOnConnect,connectionMode:j.connectionMode,connectionRadius:j.connectionRadius,domNode:j.domNode,nodeLookup:j.nodeLookup,lib:j.lib,isTarget:M,handleId:S,nodeId:E,flowId:j.rfId,panBy:j.panBy,cancelConnection:j.cancelConnection,onConnectStart:j.onConnectStart,onConnectEnd:(...F)=>{var C,z;return(z=(C=N.getState()).onConnectEnd)==null?void 0:z.call(C,...F)},updateConnection:j.updateConnection,onConnect:ee,isValidConnection:o||((...F)=>{var C,z;return((z=(C=N.getState()).isValidConnection)==null?void 0:z.call(C,...F))??!0}),getTransform:()=>N.getState().transform,getFromHandle:()=>N.getState().connection.fromHandle,autoPanSpeed:j.autoPanSpeed,dragThreshold:j.connectionDragThreshold})}A?m==null||m(U):v==null||v(U)},k=U=>{const{onClickConnectStart:A,onClickConnectEnd:j,connectionClickStartHandle:F,connectionMode:C,isValidConnection:z,lib:ne,rfId:ie,nodeLookup:ce,connection:O}=N.getState();if(!E||!F&&!u)return;if(!F){A==null||A(U.nativeEvent,{nodeId:E,handleId:S,handleType:t}),N.setState({connectionClickStartHandle:{nodeId:E,type:t,id:S}});return}const re=Bp(U.target),Q=o||z,{connection:le,isValid:pe}=Wa.isValid(U.nativeEvent,{handle:{nodeId:E,id:S,type:t},connectionMode:C,fromNodeId:F.nodeId,fromHandleId:F.id||null,fromType:F.type,isValidConnection:Q,flowId:ie,doc:re,lib:ne,nodeLookup:ce});pe&&le&&ee(le);const we=structuredClone(O);delete we.inProgress,we.toPosition=we.toHandle?we.toHandle.position:null,j==null||j(U,we),N.setState({connectionClickStartHandle:null})};return P.jsx("div",{"data-handleid":S,"data-nodeid":E,"data-handlepos":r,"data-id":`${I}-${E}-${S}-${t}`,className:be(["react-flow__handle",`react-flow__handle-${r}`,"nodrag",_,y,{source:!M,target:M,connectable:l,connectablestart:u,connectableend:c,clickconnecting:V,connectingfrom:W,connectingto:$,valid:Z,connectionindicator:l&&(!K||X)&&(K||te?c:u)}]),onMouseDown:J,onTouchStart:J,onClick:R?k:void 0,ref:w,...g,children:p})}const ll=G.memo(dg(uS));function aS({data:t,isConnectable:r,sourcePosition:o=xe.Bottom}){return P.jsxs(P.Fragment,{children:[t==null?void 0:t.label,P.jsx(ll,{type:"source",position:o,isConnectable:r})]})}function cS({data:t,isConnectable:r,targetPosition:o=xe.Top,sourcePosition:l=xe.Bottom}){return P.jsxs(P.Fragment,{children:[P.jsx(ll,{type:"target",position:o,isConnectable:r}),t==null?void 0:t.label,P.jsx(ll,{type:"source",position:l,isConnectable:r})]})}function fS(){return null}function dS({data:t,isConnectable:r,targetPosition:o=xe.Top}){return P.jsxs(P.Fragment,{children:[P.jsx(ll,{type:"target",position:o,isConnectable:r}),t==null?void 0:t.label]})}const ul={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}},Hh={input:aS,default:cS,output:dS,group:fS};function hS(t){var r,o,l,u;return t.internals.handleBounds===void 0?{width:t.width??t.initialWidth??((r=t.style)==null?void 0:r.width),height:t.height??t.initialHeight??((o=t.style)==null?void 0:o.height)}:{width:t.width??((l=t.style)==null?void 0:l.width),height:t.height??((u=t.style)==null?void 0:u.height)}}const pS=t=>{const{width:r,height:o,x:l,y:u}=mo(t.nodeLookup,{filter:c=>!!c.selected});return{width:Wt(r)?r:null,height:Wt(o)?o:null,userSelectionActive:t.userSelectionActive,transformString:`translate(${t.transform[0]}px,${t.transform[1]}px) scale(${t.transform[2]}) translate(${l}px,${u}px)`}};function gS({onSelectionContextMenu:t,noPanClassName:r,disableKeyboardA11y:o}){const l=$e(),{width:u,height:c,transformString:f,userSelectionActive:h}=Ie(pS,Ve),p=mg(),y=G.useRef(null);G.useEffect(()=>{var w;o||(w=y.current)==null||w.focus({preventScroll:!0})},[o]);const m=!h&&u!==null&&c!==null;if(gg({nodeRef:y,disabled:!m}),!m)return null;const v=t?w=>{const S=l.getState().nodes.filter(M=>M.selected);t(w,S)}:void 0,g=w=>{Object.prototype.hasOwnProperty.call(ul,w.key)&&(w.preventDefault(),p({direction:ul[w.key],factor:w.shiftKey?4:1}))};return P.jsx("div",{className:be(["react-flow__nodesselection","react-flow__container",r]),style:{transform:f},children:P.jsx("div",{ref:y,className:"react-flow__nodesselection-rect",onContextMenu:v,tabIndex:o?void 0:-1,onKeyDown:o?void 0:g,style:{width:u,height:c}})})}const Vh=typeof window<"u"?window:void 0,mS=t=>({nodesSelectionActive:t.nodesSelectionActive,userSelectionActive:t.userSelectionActive});function xg({children:t,onPaneClick:r,onPaneMouseEnter:o,onPaneMouseMove:l,onPaneMouseLeave:u,onPaneContextMenu:c,onPaneScroll:f,paneClickDistance:h,deleteKeyCode:p,selectionKeyCode:y,selectionOnDrag:m,selectionMode:v,onSelectionStart:g,onSelectionEnd:w,multiSelectionKeyCode:S,panActivationKeyCode:M,zoomActivationKeyCode:N,elementsSelectable:E,zoomOnScroll:R,zoomOnPinch:_,panOnScroll:I,panOnScrollSpeed:W,panOnScrollMode:$,zoomOnDoubleClick:V,panOnDrag:X,autoPanOnSelection:K,defaultViewport:te,translateExtent:Z,minZoom:ee,maxZoom:J,preventScrolling:k,onSelectionContextMenu:B,noWheelClassName:H,noPanClassName:U,disableKeyboardA11y:A,onViewportChange:j,isControlledViewport:F}){const{nodesSelectionActive:C,userSelectionActive:z}=Ie(mS,Ve),ne=co(y,{target:Vh}),ie=co(M,{target:Vh}),ce=ie||X,O=ie||I,re=m&&ce!==!0,Q=ne||z||re;return X1({deleteKeyCode:p,multiSelectionKeyCode:S}),P.jsx(K1,{onPaneContextMenu:c,elementsSelectable:E,zoomOnScroll:R,zoomOnPinch:_,panOnScroll:O,panActivationKeyPressed:ie,panOnScrollSpeed:W,panOnScrollMode:$,zoomOnDoubleClick:V,panOnDrag:!ne&&ce,defaultViewport:te,translateExtent:Z,minZoom:ee,maxZoom:J,zoomActivationKeyCode:N,preventScrolling:k,noWheelClassName:H,noPanClassName:U,onViewportChange:j,isControlledViewport:F,paneClickDistance:h,selectionOnDrag:re,children:P.jsxs(eS,{onSelectionStart:g,onSelectionEnd:w,onPaneClick:r,onPaneMouseEnter:o,onPaneMouseMove:l,onPaneMouseLeave:u,onPaneContextMenu:c,onPaneScroll:f,panOnDrag:ce,autoPanOnSelection:K,isSelecting:!!Q,selectionMode:v,selectionKeyPressed:ne,paneClickDistance:h,selectionOnDrag:re,children:[t,C&&P.jsx(gS,{onSelectionContextMenu:B,noPanClassName:U,disableKeyboardA11y:A})]})})}xg.displayName="FlowRenderer";const yS=G.memo(xg),vS=t=>r=>t?ec(r.nodeLookup,{x:0,y:0,width:r.width,height:r.height},r.transform,!0).map(o=>o.id):Array.from(r.nodeLookup.keys());function xS(t){return Ie(G.useCallback(vS(t),[t]),Ve)}const wS=t=>t.updateNodeInternals;function SS(){const t=Ie(wS),[r]=G.useState(()=>typeof ResizeObserver>"u"?null:new ResizeObserver(o=>{const l=new Map;o.forEach(u=>{const c=u.target.getAttribute("data-id");l.set(c,{id:c,nodeElement:u.target,force:!0})}),t(l)}));return G.useEffect(()=>()=>{r==null||r.disconnect()},[r]),r}function _S({node:t,nodeType:r,hasDimensions:o,resizeObserver:l}){const u=$e(),c=G.useRef(null),f=G.useRef(null),h=G.useRef(t.sourcePosition),p=G.useRef(t.targetPosition),y=G.useRef(r),m=o&&!!t.internals.handleBounds;return G.useEffect(()=>{c.current&&!t.hidden&&(!m||f.current!==c.current)&&(f.current&&(l==null||l.unobserve(f.current)),l==null||l.observe(c.current),f.current=c.current)},[m,t.hidden]),G.useEffect(()=>()=>{f.current&&(l==null||l.unobserve(f.current),f.current=null)},[]),G.useEffect(()=>{if(c.current){const v=y.current!==r,g=h.current!==t.sourcePosition,w=p.current!==t.targetPosition;(v||g||w)&&(y.current=r,h.current=t.sourcePosition,p.current=t.targetPosition,u.getState().updateNodeInternals(new Map([[t.id,{id:t.id,nodeElement:c.current,force:!0}]])))}},[t.id,r,t.sourcePosition,t.targetPosition]),c}function ES({id:t,onClick:r,onMouseEnter:o,onMouseMove:l,onMouseLeave:u,onContextMenu:c,onDoubleClick:f,nodesDraggable:h,elementsSelectable:p,nodesConnectable:y,nodesFocusable:m,resizeObserver:v,noDragClassName:g,noPanClassName:w,disableKeyboardA11y:S,rfId:M,nodeTypes:N,nodeClickDistance:E,onError:R}){const{node:_,internals:I,isParent:W}=Ie(Q=>{const le=Q.nodeLookup.get(t),pe=Q.parentLookup.has(t);return{node:le,internals:le.internals,isParent:pe}},Ve);let $=_.type||"default",V=(N==null?void 0:N[$])||Hh[$];V===void 0&&(R==null||R("003",Yt.error003($)),$="default",V=(N==null?void 0:N.default)||Hh.default);const X=!!(_.draggable||h&&typeof _.draggable>"u"),K=!!(_.selectable||p&&typeof _.selectable>"u"),te=!!(_.connectable||y&&typeof _.connectable>"u"),Z=!!(_.focusable||m&&typeof _.focusable>"u"),ee=$e(),J=Hp(_),k=_S({node:_,nodeType:$,hasDimensions:J,resizeObserver:v}),B=gg({nodeRef:k,disabled:_.hidden||!X,noDragClassName:g,handleSelector:_.dragHandle,nodeId:t,isSelectable:K,nodeClickDistance:E}),H=mg();if(_.hidden)return null;const U=Qt(_),A=hS(_),j=K||X||r||o||l||u,F=o?Q=>o(Q,{...I.userNode}):void 0,C=l?Q=>l(Q,{...I.userNode}):void 0,z=u?Q=>u(Q,{...I.userNode}):void 0,ne=c?Q=>c(Q,{...I.userNode}):void 0,ie=f?Q=>f(Q,{...I.userNode}):void 0,ce=Q=>{const{selectNodesOnDrag:le,nodeDragThreshold:pe}=ee.getState();K&&(!le||!X||pe>0)&&ba({id:t,store:ee,nodeRef:k}),r&&r(Q,{...I.userNode})},O=Q=>{if(!(Up(Q.nativeEvent)||S)){if(Tp.includes(Q.key)&&K){const le=Q.key==="Escape";ba({id:t,store:ee,unselect:le,nodeRef:k})}else if(X&&_.selected&&Object.prototype.hasOwnProperty.call(ul,Q.key)){Q.preventDefault();const{ariaLabelConfig:le}=ee.getState();ee.setState({ariaLiveMessage:le["node.a11yDescription.ariaLiveMessage"]({direction:Q.key.replace("Arrow","").toLowerCase(),x:~~I.positionAbsolute.x,y:~~I.positionAbsolute.y})}),H({direction:ul[Q.key],factor:Q.shiftKey?4:1})}}},re=()=>{var ke;if(S||!((ke=k.current)!=null&&ke.matches(":focus-visible")))return;const{transform:Q,width:le,height:pe,autoPanOnNodeFocus:we,setCenter:ve}=ee.getState();if(!we)return;ec(new Map([[t,_]]),{x:0,y:0,width:le,height:pe},Q,!0).length>0||ve(_.position.x+U.width/2,_.position.y+U.height/2,{zoom:Q[2]})};return P.jsx("div",{className:be(["react-flow__node",`react-flow__node-${$}`,{[w]:X},_.className,{selected:_.selected,selectable:K,parent:W,draggable:X,dragging:B}]),ref:k,style:{zIndex:I.z,transform:`translate(${I.positionAbsolute.x}px,${I.positionAbsolute.y}px)`,pointerEvents:j?"all":"none",visibility:J?"visible":"hidden",..._.style,...A},"data-id":t,"data-testid":`rf__node-${t}`,onMouseEnter:F,onMouseMove:C,onMouseLeave:z,onContextMenu:ne,onClick:ce,onDoubleClick:ie,onKeyDown:Z?O:void 0,tabIndex:Z?0:void 0,onFocus:Z?re:void 0,role:_.ariaRole??(Z?"group":void 0),"aria-roledescription":"node","aria-describedby":S?void 0:`${ug}-${M}`,"aria-label":_.ariaLabel,..._.domAttributes,children:P.jsx(nS,{value:t,children:P.jsx(V,{id:t,data:_.data,type:$,positionAbsoluteX:I.positionAbsolute.x,positionAbsoluteY:I.positionAbsolute.y,selected:_.selected??!1,selectable:K,draggable:X,deletable:_.deletable??!0,isConnectable:te,sourcePosition:_.sourcePosition,targetPosition:_.targetPosition,dragging:B,dragHandle:_.dragHandle,zIndex:I.z,parentId:_.parentId,...U})})})}var kS=G.memo(ES);const NS=t=>({nodesConnectable:t.nodesConnectable,nodesFocusable:t.nodesFocusable,elementsSelectable:t.elementsSelectable,onError:t.onError});function wg(t){const{nodesConnectable:r,nodesFocusable:o,elementsSelectable:l,onError:u}=Ie(NS,Ve),c=xS(t.onlyRenderVisibleElements),f=SS();return P.jsx("div",{className:"react-flow__nodes",style:xl,children:c.map(h=>P.jsx(kS,{id:h,nodeTypes:t.nodeTypes,nodeExtent:t.nodeExtent,onClick:t.onNodeClick,onMouseEnter:t.onNodeMouseEnter,onMouseMove:t.onNodeMouseMove,onMouseLeave:t.onNodeMouseLeave,onContextMenu:t.onNodeContextMenu,onDoubleClick:t.onNodeDoubleClick,noDragClassName:t.noDragClassName,noPanClassName:t.noPanClassName,rfId:t.rfId,disableKeyboardA11y:t.disableKeyboardA11y,resizeObserver:f,nodesDraggable:t.nodesDraggable??!0,nodesConnectable:r,nodesFocusable:o,elementsSelectable:l,nodeClickDistance:t.nodeClickDistance,onError:u},h))})}wg.displayName="NodeRenderer";const CS=G.memo(wg);function MS(t){return Ie(G.useCallback(o=>{if(!t)return o.edges.map(u=>u.id);const l=[];if(o.width&&o.height)for(const u of o.edges){const c=o.nodeLookup.get(u.source),f=o.nodeLookup.get(u.target);c&&f&&ww({sourceNode:c,targetNode:f,width:o.width,height:o.height,transform:o.transform})&&l.push(u.id)}return l},[t]),Ve)}const PS=({color:t="none",strokeWidth:r=1})=>{const o={strokeWidth:r,...t&&{stroke:t}};return P.jsx("polyline",{className:"arrow",style:o,strokeLinecap:"round",fill:"none",strokeLinejoin:"round",points:"-5,-4 0,0 -5,4"})},IS=({color:t="none",strokeWidth:r=1})=>{const o={strokeWidth:r,...t&&{stroke:t,fill:t}};return P.jsx("polyline",{className:"arrowclosed",style:o,strokeLinecap:"round",strokeLinejoin:"round",points:"-5,-4 0,0 -5,4 -5,-4"})},Bh={[il.Arrow]:PS,[il.ArrowClosed]:IS};function TS(t){const r=$e();return G.useMemo(()=>{var u,c;return Object.prototype.hasOwnProperty.call(Bh,t)?Bh[t]:((c=(u=r.getState()).onError)==null||c.call(u,"009",Yt.error009(t)),null)},[t])}const zS=({id:t,type:r,color:o,width:l=12.5,height:u=12.5,markerUnits:c="strokeWidth",strokeWidth:f,orient:h="auto-start-reverse"})=>{const p=TS(r);return p?P.jsx("marker",{className:"react-flow__arrowhead",id:t,markerWidth:`${l}`,markerHeight:`${u}`,viewBox:"-10 -10 20 20",markerUnits:c,orient:h,refX:"0",refY:"0",children:P.jsx(p,{color:o,strokeWidth:f})}):null},Sg=({defaultColor:t,rfId:r})=>{const o=Ie(c=>c.edges),l=Ie(c=>c.defaultEdgeOptions),u=G.useMemo(()=>Pw(o,{id:r,defaultColor:t,defaultMarkerStart:l==null?void 0:l.markerStart,defaultMarkerEnd:l==null?void 0:l.markerEnd}),[o,l,r,t]);return u.length?P.jsx("svg",{className:"react-flow__marker","aria-hidden":"true",children:P.jsx("defs",{children:u.map(c=>P.jsx(zS,{id:c.id,type:c.type,color:c.color,width:c.width,height:c.height,markerUnits:c.markerUnits,strokeWidth:c.strokeWidth,orient:c.orient},c.id))})}):null};Sg.displayName="MarkerDefinitions";var RS=G.memo(Sg);function _g({x:t,y:r,label:o,labelStyle:l,labelShowBg:u=!0,labelBgStyle:c,labelBgPadding:f=[2,4],labelBgBorderRadius:h=2,children:p,className:y,...m}){const[v,g]=G.useState({x:1,y:0,width:0,height:0}),w=be(["react-flow__edge-textwrapper",y]),S=G.useRef(null);return G.useEffect(()=>{if(S.current){const M=S.current.getBBox();g({x:M.x,y:M.y,width:M.width,height:M.height})}},[o]),o?P.jsxs("g",{transform:`translate(${t-v.width/2} ${r-v.height/2})`,className:w,visibility:v.width?"visible":"hidden",...m,children:[u&&P.jsx("rect",{width:v.width+2*f[0],x:-f[0],y:-f[1],height:v.height+2*f[1],className:"react-flow__edge-textbg",style:c,rx:h,ry:h}),P.jsx("text",{className:"react-flow__edge-text",y:v.height/2,dy:"0.3em",ref:S,style:l,children:o}),p]}):null}_g.displayName="EdgeText";const LS=G.memo(_g);function wl({path:t,labelX:r,labelY:o,label:l,labelStyle:u,labelShowBg:c,labelBgStyle:f,labelBgPadding:h,labelBgBorderRadius:p,interactionWidth:y=20,...m}){return P.jsxs(P.Fragment,{children:[P.jsx("path",{...m,d:t,fill:"none",className:be(["react-flow__edge-path",m.className])}),y?P.jsx("path",{d:t,fill:"none",strokeOpacity:0,strokeWidth:y,className:"react-flow__edge-interaction"}):null,l&&Wt(r)&&Wt(o)?P.jsx(LS,{x:r,y:o,label:l,labelStyle:u,labelShowBg:c,labelBgStyle:f,labelBgPadding:h,labelBgBorderRadius:p}):null]})}function Uh({pos:t,x1:r,y1:o,x2:l,y2:u}){return t===xe.Left||t===xe.Right?[.5*(r+l),o]:[r,.5*(o+u)]}function Eg({sourceX:t,sourceY:r,sourcePosition:o=xe.Bottom,targetX:l,targetY:u,targetPosition:c=xe.Top}){const[f,h]=Uh({pos:o,x1:t,y1:r,x2:l,y2:u}),[p,y]=Uh({pos:c,x1:l,y1:u,x2:t,y2:r}),[m,v,g,w]=bp({sourceX:t,sourceY:r,targetX:l,targetY:u,sourceControlX:f,sourceControlY:h,targetControlX:p,targetControlY:y});return[`M${t},${r} C${f},${h} ${p},${y} ${l},${u}`,m,v,g,w]}function kg(t){return G.memo(({id:r,sourceX:o,sourceY:l,targetX:u,targetY:c,sourcePosition:f,targetPosition:h,label:p,labelStyle:y,labelShowBg:m,labelBgStyle:v,labelBgPadding:g,labelBgBorderRadius:w,style:S,markerEnd:M,markerStart:N,interactionWidth:E})=>{const[R,_,I]=Eg({sourceX:o,sourceY:l,sourcePosition:f,targetX:u,targetY:c,targetPosition:h}),W=t.isInternal?void 0:r;return P.jsx(wl,{id:W,path:R,labelX:_,labelY:I,label:p,labelStyle:y,labelShowBg:m,labelBgStyle:v,labelBgPadding:g,labelBgBorderRadius:w,style:S,markerEnd:M,markerStart:N,interactionWidth:E})})}const jS=kg({isInternal:!1}),Ng=kg({isInternal:!0});jS.displayName="SimpleBezierEdge";Ng.displayName="SimpleBezierEdgeInternal";function Cg(t){return G.memo(({id:r,sourceX:o,sourceY:l,targetX:u,targetY:c,label:f,labelStyle:h,labelShowBg:p,labelBgStyle:y,labelBgPadding:m,labelBgBorderRadius:v,style:g,sourcePosition:w=xe.Bottom,targetPosition:S=xe.Top,markerEnd:M,markerStart:N,pathOptions:E,interactionWidth:R})=>{const[_,I,W]=Va({sourceX:o,sourceY:l,sourcePosition:w,targetX:u,targetY:c,targetPosition:S,borderRadius:E==null?void 0:E.borderRadius,offset:E==null?void 0:E.offset,stepPosition:E==null?void 0:E.stepPosition}),$=t.isInternal?void 0:r;return P.jsx(wl,{id:$,path:_,labelX:I,labelY:W,label:f,labelStyle:h,labelShowBg:p,labelBgStyle:y,labelBgPadding:m,labelBgBorderRadius:v,style:g,markerEnd:M,markerStart:N,interactionWidth:R})})}const Mg=Cg({isInternal:!1}),Pg=Cg({isInternal:!0});Mg.displayName="SmoothStepEdge";Pg.displayName="SmoothStepEdgeInternal";function Ig(t){return G.memo(({id:r,...o})=>{var u;const l=t.isInternal?void 0:r;return P.jsx(Mg,{...o,id:l,pathOptions:G.useMemo(()=>{var c;return{borderRadius:0,offset:(c=o.pathOptions)==null?void 0:c.offset}},[(u=o.pathOptions)==null?void 0:u.offset])})})}const AS=Ig({isInternal:!1}),Tg=Ig({isInternal:!0});AS.displayName="StepEdge";Tg.displayName="StepEdgeInternal";function zg(t){return G.memo(({id:r,sourceX:o,sourceY:l,targetX:u,targetY:c,label:f,labelStyle:h,labelShowBg:p,labelBgStyle:y,labelBgPadding:m,labelBgBorderRadius:v,style:g,markerEnd:w,markerStart:S,interactionWidth:M})=>{const[N,E,R]=Qp({sourceX:o,sourceY:l,targetX:u,targetY:c}),_=t.isInternal?void 0:r;return P.jsx(wl,{id:_,path:N,labelX:E,labelY:R,label:f,labelStyle:h,labelShowBg:p,labelBgStyle:y,labelBgPadding:m,labelBgBorderRadius:v,style:g,markerEnd:w,markerStart:S,interactionWidth:M})})}const $S=zg({isInternal:!1}),Rg=zg({isInternal:!0});$S.displayName="StraightEdge";Rg.displayName="StraightEdgeInternal";function Lg(t){return G.memo(({id:r,sourceX:o,sourceY:l,targetX:u,targetY:c,sourcePosition:f=xe.Bottom,targetPosition:h=xe.Top,label:p,labelStyle:y,labelShowBg:m,labelBgStyle:v,labelBgPadding:g,labelBgBorderRadius:w,style:S,markerEnd:M,markerStart:N,pathOptions:E,interactionWidth:R})=>{const[_,I,W]=Yp({sourceX:o,sourceY:l,sourcePosition:f,targetX:u,targetY:c,targetPosition:h,curvature:E==null?void 0:E.curvature}),$=t.isInternal?void 0:r;return P.jsx(wl,{id:$,path:_,labelX:I,labelY:W,label:p,labelStyle:y,labelShowBg:m,labelBgStyle:v,labelBgPadding:g,labelBgBorderRadius:w,style:S,markerEnd:M,markerStart:N,interactionWidth:R})})}const DS=Lg({isInternal:!1}),jg=Lg({isInternal:!0});DS.displayName="BezierEdge";jg.displayName="BezierEdgeInternal";const Wh={default:jg,straight:Rg,step:Tg,smoothstep:Pg,simplebezier:Ng},bh={sourceX:null,sourceY:null,targetX:null,targetY:null,sourcePosition:null,targetPosition:null,zIndex:void 0},OS=(t,r,o)=>o===xe.Left?t-r:o===xe.Right?t+r:t,FS=(t,r,o)=>o===xe.Top?t-r:o===xe.Bottom?t+r:t,Yh="react-flow__edgeupdater";function Xh({position:t,centerX:r,centerY:o,radius:l=10,onMouseDown:u,onMouseEnter:c,onMouseOut:f,type:h}){return P.jsx("circle",{onMouseDown:u,onMouseEnter:c,onMouseOut:f,className:be([Yh,`${Yh}-${h}`]),cx:OS(r,l,t),cy:FS(o,l,t),r:l,stroke:"transparent",fill:"transparent"})}function HS({isReconnectable:t,reconnectRadius:r,edge:o,sourceX:l,sourceY:u,targetX:c,targetY:f,sourcePosition:h,targetPosition:p,onReconnect:y,onReconnectStart:m,onReconnectEnd:v,setReconnecting:g,setUpdateHover:w}){const S=$e(),M=(I,W)=>{if(I.button!==0)return;const{autoPanOnConnect:$,domNode:V,connectionMode:X,connectionRadius:K,lib:te,onConnectStart:Z,cancelConnection:ee,nodeLookup:J,rfId:k,panBy:B,updateConnection:H}=S.getState(),U=W.type==="target",A=(C,z)=>{g(!1),v==null||v(C,o,W.type,z)},j=C=>y==null?void 0:y(o,C),F=(C,z)=>{g(!0),m==null||m(I,o,W.type),Z==null||Z(C,z)};Wa.onPointerDown(I.nativeEvent,{autoPanOnConnect:$,connectionMode:X,connectionRadius:K,domNode:V,handleId:W.id,nodeId:W.nodeId,nodeLookup:J,isTarget:U,edgeUpdaterType:W.type,lib:te,flowId:k,cancelConnection:ee,panBy:B,isValidConnection:(...C)=>{var z,ne;return((ne=(z=S.getState()).isValidConnection)==null?void 0:ne.call(z,...C))??!0},onConnect:j,onConnectStart:F,onConnectEnd:(...C)=>{var z,ne;return(ne=(z=S.getState()).onConnectEnd)==null?void 0:ne.call(z,...C)},onReconnectEnd:A,updateConnection:H,getTransform:()=>S.getState().transform,getFromHandle:()=>S.getState().connection.fromHandle,dragThreshold:S.getState().connectionDragThreshold,handleDomNode:I.currentTarget})},N=I=>M(I,{nodeId:o.target,id:o.targetHandle??null,type:"target"}),E=I=>M(I,{nodeId:o.source,id:o.sourceHandle??null,type:"source"}),R=()=>w(!0),_=()=>w(!1);return P.jsxs(P.Fragment,{children:[(t===!0||t==="source")&&P.jsx(Xh,{position:h,centerX:l,centerY:u,radius:r,onMouseDown:N,onMouseEnter:R,onMouseOut:_,type:"source"}),(t===!0||t==="target")&&P.jsx(Xh,{position:p,centerX:c,centerY:f,radius:r,onMouseDown:E,onMouseEnter:R,onMouseOut:_,type:"target"})]})}function VS({id:t,edgesFocusable:r,edgesReconnectable:o,elementsSelectable:l,onClick:u,onDoubleClick:c,onContextMenu:f,onMouseEnter:h,onMouseMove:p,onMouseLeave:y,reconnectRadius:m,onReconnect:v,onReconnectStart:g,onReconnectEnd:w,rfId:S,edgeTypes:M,noPanClassName:N,onError:E,disableKeyboardA11y:R}){let _=Ie(ve=>ve.edgeLookup.get(t));const I=Ie(ve=>ve.defaultEdgeOptions);_=I?{...I,..._}:_;let W=_.type||"default",$=(M==null?void 0:M[W])||Wh[W];$===void 0&&(E==null||E("011",Yt.error011(W)),W="default",$=(M==null?void 0:M.default)||Wh.default);const V=!!(_.focusable||r&&typeof _.focusable>"u"),X=typeof v<"u"&&(_.reconnectable||o&&typeof _.reconnectable>"u"),K=!!(_.selectable||l&&typeof _.selectable>"u"),te=G.useRef(null),[Z,ee]=G.useState(!1),[J,k]=G.useState(!1),B=$e(),{zIndex:H=_.zIndex,sourceX:U,sourceY:A,targetX:j,targetY:F,sourcePosition:C,targetPosition:z}=Ie(G.useCallback(ve=>{const me=ve.nodeLookup.get(_.source),ke=ve.nodeLookup.get(_.target);if(!me||!ke)return bh;const Ce=Mw({id:t,sourceNode:me,targetNode:ke,sourceHandle:_.sourceHandle||null,targetHandle:_.targetHandle||null,connectionMode:ve.connectionMode,onError:E}),Me=xw({selected:_.selected,zIndex:_.zIndex,sourceNode:me,targetNode:ke,elevateOnSelect:ve.elevateEdgesOnSelect,zIndexMode:ve.zIndexMode});return{...Ce||bh,zIndex:Me}},[_.source,_.target,_.sourceHandle,_.targetHandle,_.selected,_.zIndex,E]),Ve),ne=G.useMemo(()=>_.markerStart?`url('#${Ba(_.markerStart,S)}')`:void 0,[_.markerStart,S]),ie=G.useMemo(()=>_.markerEnd?`url('#${Ba(_.markerEnd,S)}')`:void 0,[_.markerEnd,S]);if(_.hidden||U===null||A===null||j===null||F===null)return null;const ce=ve=>{var Me;const{addSelectedEdges:me,unselectNodesAndEdges:ke,multiSelectionActive:Ce}=B.getState();K&&(B.setState({nodesSelectionActive:!1}),_.selected&&Ce?(ke({nodes:[],edges:[_]}),(Me=te.current)==null||Me.blur()):me([t])),u&&u(ve,_)},O=c?ve=>{c(ve,{..._})}:void 0,re=f?ve=>{f(ve,{..._})}:void 0,Q=h?ve=>{h(ve,{..._})}:void 0,le=p?ve=>{p(ve,{..._})}:void 0,pe=y?ve=>{y(ve,{..._})}:void 0,we=ve=>{var me;if(!R&&Tp.includes(ve.key)&&K){const{unselectNodesAndEdges:ke,addSelectedEdges:Ce}=B.getState();ve.key==="Escape"?((me=te.current)==null||me.blur(),ke({edges:[_]})):Ce([t])}};return P.jsx("svg",{style:{zIndex:H},children:P.jsxs("g",{className:be(["react-flow__edge",`react-flow__edge-${W}`,_.className,N,{selected:_.selected,animated:_.animated,inactive:!K&&!u,updating:Z,selectable:K}]),onClick:ce,onDoubleClick:O,onContextMenu:re,onMouseEnter:Q,onMouseMove:le,onMouseLeave:pe,onKeyDown:V?we:void 0,tabIndex:V?0:void 0,role:_.ariaRole??(V?"group":"img"),"aria-roledescription":"edge","data-id":t,"data-testid":`rf__edge-${t}`,"aria-label":_.ariaLabel===null?void 0:_.ariaLabel||`Edge from ${_.source} to ${_.target}`,"aria-describedby":V?`${ag}-${S}`:void 0,ref:te,..._.domAttributes,children:[!J&&P.jsx($,{id:t,source:_.source,target:_.target,type:_.type,selected:_.selected,animated:_.animated,selectable:K,deletable:_.deletable??!0,label:_.label,labelStyle:_.labelStyle,labelShowBg:_.labelShowBg,labelBgStyle:_.labelBgStyle,labelBgPadding:_.labelBgPadding,labelBgBorderRadius:_.labelBgBorderRadius,sourceX:U,sourceY:A,targetX:j,targetY:F,sourcePosition:C,targetPosition:z,data:_.data,style:_.style,sourceHandleId:_.sourceHandle,targetHandleId:_.targetHandle,markerStart:ne,markerEnd:ie,pathOptions:"pathOptions"in _?_.pathOptions:void 0,interactionWidth:_.interactionWidth}),X&&P.jsx(HS,{edge:_,isReconnectable:X,reconnectRadius:m,onReconnect:v,onReconnectStart:g,onReconnectEnd:w,sourceX:U,sourceY:A,targetX:j,targetY:F,sourcePosition:C,targetPosition:z,setUpdateHover:ee,setReconnecting:k})]})})}var BS=G.memo(VS);const US=t=>({edgesFocusable:t.edgesFocusable,edgesReconnectable:t.edgesReconnectable,elementsSelectable:t.elementsSelectable,connectionMode:t.connectionMode,onError:t.onError});function Ag({defaultMarkerColor:t,onlyRenderVisibleElements:r,rfId:o,edgeTypes:l,noPanClassName:u,onReconnect:c,onEdgeContextMenu:f,onEdgeMouseEnter:h,onEdgeMouseMove:p,onEdgeMouseLeave:y,onEdgeClick:m,reconnectRadius:v,onEdgeDoubleClick:g,onReconnectStart:w,onReconnectEnd:S,disableKeyboardA11y:M}){const{edgesFocusable:N,edgesReconnectable:E,elementsSelectable:R,onError:_}=Ie(US,Ve),I=MS(r);return P.jsxs("div",{className:"react-flow__edges",children:[P.jsx(RS,{defaultColor:t,rfId:o}),I.map(W=>P.jsx(BS,{id:W,edgesFocusable:N,edgesReconnectable:E,elementsSelectable:R,noPanClassName:u,onReconnect:c,onContextMenu:f,onMouseEnter:h,onMouseMove:p,onMouseLeave:y,onClick:m,reconnectRadius:v,onDoubleClick:g,onReconnectStart:w,onReconnectEnd:S,rfId:o,onError:_,edgeTypes:l,disableKeyboardA11y:M},W))]})}Ag.displayName="EdgeRenderer";const WS=G.memo(Ag),Qh=t=>`translate(${t[0]}px,${t[1]}px) scale(${t[2]})`;function bS({children:t}){const r=$e(),o=G.useRef(null),[l]=G.useState(()=>r.getState().transform);return hg(()=>{let u=null;const c=()=>{const f=r.getState().transform;u&&f[0]===u[0]&&f[1]===u[1]&&f[2]===u[2]||(u=f,o.current&&(o.current.style.transform=Qh(f)))};return c(),r.subscribe(c)},[r]),P.jsx("div",{ref:o,className:"react-flow__viewport xyflow__viewport react-flow__container",style:{transform:Qh(l)},children:t})}function YS(t){const r=ac(),o=G.useRef(!1);G.useEffect(()=>{!o.current&&r.viewportInitialized&&t&&(setTimeout(()=>t(r),1),o.current=!0)},[t,r.viewportInitialized])}const XS=t=>{var r;return(r=t.panZoom)==null?void 0:r.syncViewport};function QS(t){const r=Ie(XS),o=$e();return G.useEffect(()=>{t&&(r==null||r(t),o.setState({transform:[t.x,t.y,t.zoom]}))},[t,r]),null}function GS(t){return t.connection.inProgress?{...t.connection,to:vo(t.connection.to,t.transform)}:{...t.connection}}function KS(t){return GS}function qS(t){const r=KS();return Ie(r,Ve)}const ZS=t=>({nodesConnectable:t.nodesConnectable,isValid:t.connection.isValid,inProgress:t.connection.inProgress,width:t.width,height:t.height});function JS({containerStyle:t,style:r,type:o,component:l}){const{nodesConnectable:u,width:c,height:f,isValid:h,inProgress:p}=Ie(ZS,Ve);return!(c&&u&&p)?null:P.jsx("svg",{style:t,width:c,height:f,className:"react-flow__connectionline react-flow__container",children:P.jsx("g",{className:be(["react-flow__connection",Lp(h)]),children:P.jsx($g,{style:r,type:o,CustomComponent:l,isValid:h})})})}const $g=({style:t,type:r=Wn.Bezier,CustomComponent:o,isValid:l})=>{const{inProgress:u,from:c,fromNode:f,fromHandle:h,fromPosition:p,to:y,toNode:m,toHandle:v,toPosition:g,pointer:w}=qS();if(!u)return;if(o)return P.jsx(o,{connectionLineType:r,connectionLineStyle:t,fromNode:f,fromHandle:h,fromX:c.x,fromY:c.y,toX:y.x,toY:y.y,fromPosition:p,toPosition:g,connectionStatus:Lp(l),toNode:m,toHandle:v,pointer:w});let S="";const M={sourceX:c.x,sourceY:c.y,sourcePosition:p,targetX:y.x,targetY:y.y,targetPosition:g};switch(r){case Wn.Bezier:[S]=Yp(M);break;case Wn.SimpleBezier:[S]=Eg(M);break;case Wn.Step:[S]=Va({...M,borderRadius:0});break;case Wn.SmoothStep:[S]=Va(M);break;default:[S]=Qp(M)}return P.jsx("path",{d:S,fill:"none",className:"react-flow__connection-path",style:t})};$g.displayName="ConnectionLine";const e_={};function Gh(t=e_){G.useRef(t),$e(),G.useEffect(()=>{},[t])}function t_(){$e(),G.useRef(!1),G.useEffect(()=>{},[])}function Dg({nodeTypes:t,edgeTypes:r,onInit:o,onNodeClick:l,onEdgeClick:u,onNodeDoubleClick:c,onEdgeDoubleClick:f,onNodeMouseEnter:h,onNodeMouseMove:p,onNodeMouseLeave:y,onNodeContextMenu:m,onSelectionContextMenu:v,onSelectionStart:g,onSelectionEnd:w,connectionLineType:S,connectionLineStyle:M,connectionLineComponent:N,connectionLineContainerStyle:E,selectionKeyCode:R,selectionOnDrag:_,selectionMode:I,multiSelectionKeyCode:W,panActivationKeyCode:$,zoomActivationKeyCode:V,deleteKeyCode:X,onlyRenderVisibleElements:K,elementsSelectable:te,defaultViewport:Z,translateExtent:ee,minZoom:J,maxZoom:k,preventScrolling:B,defaultMarkerColor:H,zoomOnScroll:U,zoomOnPinch:A,panOnScroll:j,panOnScrollSpeed:F,panOnScrollMode:C,zoomOnDoubleClick:z,panOnDrag:ne,autoPanOnSelection:ie,onPaneClick:ce,onPaneMouseEnter:O,onPaneMouseMove:re,onPaneMouseLeave:Q,onPaneScroll:le,onPaneContextMenu:pe,paneClickDistance:we,nodeClickDistance:ve,onEdgeContextMenu:me,onEdgeMouseEnter:ke,onEdgeMouseMove:Ce,onEdgeMouseLeave:Me,reconnectRadius:Re,onReconnect:ot,onReconnectStart:ut,onReconnectEnd:Je,noDragClassName:et,noWheelClassName:sn,noPanClassName:Gt,disableKeyboardA11y:Kt,nodeExtent:at,rfId:Ct,viewport:yt,onViewportChange:Mt,nodesDraggable:wr}){return Gh(t),Gh(r),t_(),YS(o),QS(yt),P.jsx(yS,{onPaneClick:ce,onPaneMouseEnter:O,onPaneMouseMove:re,onPaneMouseLeave:Q,onPaneContextMenu:pe,onPaneScroll:le,paneClickDistance:we,deleteKeyCode:X,selectionKeyCode:R,selectionOnDrag:_,selectionMode:I,onSelectionStart:g,onSelectionEnd:w,multiSelectionKeyCode:W,panActivationKeyCode:$,zoomActivationKeyCode:V,elementsSelectable:te,zoomOnScroll:U,zoomOnPinch:A,zoomOnDoubleClick:z,panOnScroll:j,panOnScrollSpeed:F,panOnScrollMode:C,panOnDrag:ne,autoPanOnSelection:ie,defaultViewport:Z,translateExtent:ee,minZoom:J,maxZoom:k,onSelectionContextMenu:v,preventScrolling:B,noDragClassName:et,noWheelClassName:sn,noPanClassName:Gt,disableKeyboardA11y:Kt,onViewportChange:Mt,isControlledViewport:!!yt,children:P.jsxs(bS,{children:[P.jsx(WS,{edgeTypes:r,onEdgeClick:u,onEdgeDoubleClick:f,onReconnect:ot,onReconnectStart:ut,onReconnectEnd:Je,onlyRenderVisibleElements:K,onEdgeContextMenu:me,onEdgeMouseEnter:ke,onEdgeMouseMove:Ce,onEdgeMouseLeave:Me,reconnectRadius:Re,defaultMarkerColor:H,noPanClassName:Gt,disableKeyboardA11y:Kt,rfId:Ct}),P.jsx(JS,{style:M,type:S,component:N,containerStyle:E}),P.jsx("div",{className:"react-flow__edgelabel-renderer"}),P.jsx(CS,{nodeTypes:t,onNodeClick:l,onNodeDoubleClick:c,onNodeMouseEnter:h,onNodeMouseMove:p,onNodeMouseLeave:y,onNodeContextMenu:m,nodeClickDistance:ve,onlyRenderVisibleElements:K,noPanClassName:Gt,noDragClassName:et,disableKeyboardA11y:Kt,nodeExtent:at,rfId:Ct,nodesDraggable:wr}),P.jsx("div",{className:"react-flow__viewport-portal"})]})})}Dg.displayName="GraphView";const n_=G.memo(Dg),r_=Fp(),Kh=({nodes:t,edges:r,defaultNodes:o,defaultEdges:l,width:u,height:c,fitView:f,fitViewOptions:h,minZoom:p=.5,maxZoom:y=2,nodeOrigin:m,nodeExtent:v,zIndexMode:g="basic"}={})=>{const w=new Map,S=new Map,M=new Map,N=new Map,E=l??r??[],R=o??t??[],_=m??[0,0],I=v??so;qp(M,N,E);const{nodesInitialized:W}=Ua(R,w,S,{nodeOrigin:_,nodeExtent:I,zIndexMode:g});let $=[0,0,1];if(f&&u&&c){const V=mo(w,{filter:Z=>!!((Z.width||Z.initialWidth)&&(Z.height||Z.initialHeight))}),{x:X,y:K,zoom:te}=nc(V,u,c,p,y,(h==null?void 0:h.padding)??.1);$=[X,K,te]}return{rfId:"1",width:u??0,height:c??0,transform:$,nodes:R,nodesInitialized:W,nodeLookup:w,parentLookup:S,edges:E,edgeLookup:N,connectionLookup:M,onNodesChange:null,onEdgesChange:null,hasDefaultNodes:o!==void 0,hasDefaultEdges:l!==void 0,panZoom:null,minZoom:p,maxZoom:y,translateExtent:so,nodeExtent:I,nodesSelectionActive:!1,userSelectionActive:!1,userSelectionRect:null,connectionMode:ni.Strict,domNode:null,paneDragging:!1,noPanClassName:"nopan",nodeOrigin:_,nodeDragThreshold:1,connectionDragThreshold:1,snapGrid:[15,15],snapToGrid:!1,nodesDraggable:!0,nodesConnectable:!0,nodesFocusable:!0,edgesFocusable:!0,edgesReconnectable:!0,elementsSelectable:!0,elevateNodesOnSelect:!0,elevateEdgesOnSelect:!0,selectNodesOnDrag:!0,multiSelectionActive:!1,fitViewQueued:f??!1,fitViewOptions:h,fitViewResolver:null,connection:{...Rp},connectionClickStartHandle:null,connectOnClick:!0,ariaLiveMessage:"",autoPanOnConnect:!0,autoPanOnNodeDrag:!0,autoPanOnNodeFocus:!0,autoPanSpeed:15,connectionRadius:20,onError:r_,isValidConnection:void 0,onSelectionChangeHandlers:[],lib:"react",debug:!1,ariaLabelConfig:zp,zIndexMode:g,onNodesChangeMiddlewareMap:new Map,onEdgesChangeMiddlewareMap:new Map}},i_=({nodes:t,edges:r,defaultNodes:o,defaultEdges:l,width:u,height:c,fitView:f,fitViewOptions:h,minZoom:p,maxZoom:y,nodeOrigin:m,nodeExtent:v,zIndexMode:g})=>m1((w,S)=>{async function M(){const{nodeLookup:N,panZoom:E,fitViewOptions:R,fitViewResolver:_,width:I,height:W,minZoom:$,maxZoom:V}=S();E&&(await dw({nodes:N,width:I,height:W,panZoom:E,minZoom:$,maxZoom:V},R),_==null||_.resolve(!0),w({fitViewResolver:null}))}return{...Kh({nodes:t,edges:r,width:u,height:c,fitView:f,fitViewOptions:h,minZoom:p,maxZoom:y,nodeOrigin:m,nodeExtent:v,defaultNodes:o,defaultEdges:l,zIndexMode:g}),setNodes:N=>{const{nodeLookup:E,parentLookup:R,nodeOrigin:_,nodeExtent:I,elevateNodesOnSelect:W,fitViewQueued:$,zIndexMode:V,nodesSelectionActive:X}=S(),{nodesInitialized:K,hasSelectedNodes:te}=Ua(N,E,R,{nodeOrigin:_,nodeExtent:I,elevateNodesOnSelect:W,checkEquality:!0,zIndexMode:V}),Z=X&&te;$&&K?(M(),w({nodes:N,nodesInitialized:K,fitViewQueued:!1,fitViewOptions:void 0,nodesSelectionActive:Z})):w({nodes:N,nodesInitialized:K,nodesSelectionActive:Z})},setEdges:N=>{const{connectionLookup:E,edgeLookup:R}=S();qp(E,R,N),w({edges:N})},setDefaultNodesAndEdges:(N,E)=>{if(N){const{setNodes:R}=S();R(N),w({hasDefaultNodes:!0})}if(E){const{setEdges:R}=S();R(E),w({hasDefaultEdges:!0})}},updateNodeInternals:N=>{const{triggerNodeChanges:E,nodeLookup:R,parentLookup:_,domNode:I,nodeOrigin:W,nodeExtent:$,debug:V,fitViewQueued:X,zIndexMode:K}=S(),{changes:te,updatedInternals:Z}=Aw(N,R,_,I,W,$,K);Z&&(zw(R,_,{nodeOrigin:W,nodeExtent:$,zIndexMode:K}),X?(M(),w({fitViewQueued:!1,fitViewOptions:void 0})):w({}),(te==null?void 0:te.length)>0&&(V&&console.log("React Flow: trigger node changes",te),E==null||E(te)))},updateNodePositions:(N,E=!1)=>{const R=[];let _=[];const{nodeLookup:I,triggerNodeChanges:W,connection:$,updateConnection:V,onNodesChangeMiddlewareMap:X}=S();for(const[K,te]of N){const Z=I.get(K),ee=!!(Z!=null&&Z.expandParent&&(Z!=null&&Z.parentId)&&(te!=null&&te.position)),J={id:K,type:"position",position:ee?{x:Math.max(0,te.position.x),y:Math.max(0,te.position.y)}:te.position,dragging:E};if(Z&&$.inProgress&&$.fromNode.id===Z.id){const k=xr(Z,$.fromHandle,xe.Left,!0);V({...$,from:k})}ee&&Z.parentId&&R.push({id:K,parentId:Z.parentId,rect:{...te.internals.positionAbsolute,width:te.measured.width??0,height:te.measured.height??0}}),_.push(J)}if(R.length>0){const{parentLookup:K,nodeOrigin:te}=S(),Z=uc(R,I,K,te);_.push(...Z)}for(const K of X.values())_=K(_);W(_)},triggerNodeChanges:N=>{const{onNodesChange:E,setNodes:R,nodes:_,hasDefaultNodes:I,debug:W}=S();if(N!=null&&N.length){if(I){const $=D1(N,_);R($)}W&&console.log("React Flow: trigger node changes",N),E==null||E(N)}},triggerEdgeChanges:N=>{const{onEdgesChange:E,setEdges:R,edges:_,hasDefaultEdges:I,debug:W}=S();if(N!=null&&N.length){if(I){const $=O1(N,_);R($)}W&&console.log("React Flow: trigger edge changes",N),E==null||E(N)}},addSelectedNodes:N=>{const{multiSelectionActive:E,edgeLookup:R,nodeLookup:_,triggerNodeChanges:I,triggerEdgeChanges:W}=S();if(E){const $=N.map(V=>fr(V,!0));I($);return}I(qr(_,new Set([...N]),!0)),W(qr(R))},addSelectedEdges:N=>{const{multiSelectionActive:E,edgeLookup:R,nodeLookup:_,triggerNodeChanges:I,triggerEdgeChanges:W}=S();if(E){const $=N.map(V=>fr(V,!0));W($);return}W(qr(R,new Set([...N]))),I(qr(_,new Set,!0))},unselectNodesAndEdges:({nodes:N,edges:E}={})=>{const{edges:R,nodes:_,nodeLookup:I,triggerNodeChanges:W,triggerEdgeChanges:$}=S(),V=N||_,X=E||R,K=[];for(const Z of V){if(!Z.selected)continue;const ee=I.get(Z.id);ee&&(ee.selected=!1),K.push(fr(Z.id,!1))}const te=[];for(const Z of X)Z.selected&&te.push(fr(Z.id,!1));W(K),$(te)},setMinZoom:N=>{const{panZoom:E,maxZoom:R}=S();E==null||E.setScaleExtent([N,R]),w({minZoom:N})},setMaxZoom:N=>{const{panZoom:E,minZoom:R}=S();E==null||E.setScaleExtent([R,N]),w({maxZoom:N})},setTranslateExtent:N=>{var E;(E=S().panZoom)==null||E.setTranslateExtent(N),w({translateExtent:N})},resetSelectedElements:()=>{const{edges:N,nodes:E,triggerNodeChanges:R,triggerEdgeChanges:_,elementsSelectable:I}=S();if(!I)return;const W=E.reduce((V,X)=>X.selected?[...V,fr(X.id,!1)]:V,[]),$=N.reduce((V,X)=>X.selected?[...V,fr(X.id,!1)]:V,[]);R(W),_($)},setNodeExtent:N=>{const{nodes:E,nodeLookup:R,parentLookup:_,nodeOrigin:I,elevateNodesOnSelect:W,nodeExtent:$,zIndexMode:V}=S();N[0][0]===$[0][0]&&N[0][1]===$[0][1]&&N[1][0]===$[1][0]&&N[1][1]===$[1][1]||(Ua(E,R,_,{nodeOrigin:I,nodeExtent:N,elevateNodesOnSelect:W,checkEquality:!1,zIndexMode:V}),w({nodeExtent:N}))},panBy:N=>{const{transform:E,width:R,height:_,panZoom:I,translateExtent:W}=S();return $w({delta:N,panZoom:I,transform:E,translateExtent:W,width:R,height:_})},setCenter:async(N,E,R)=>{const{width:_,height:I,maxZoom:W,panZoom:$}=S();if(!$)return!1;const V=typeof(R==null?void 0:R.zoom)<"u"?R.zoom:W;return await $.setViewport({x:_/2-N*V,y:I/2-E*V,zoom:V},{duration:R==null?void 0:R.duration,ease:R==null?void 0:R.ease,interpolate:R==null?void 0:R.interpolate}),!0},cancelConnection:()=>{w({connection:{...Rp}})},updateConnection:N=>{w({connection:N})},reset:()=>w({...Kh()})}},Object.is);function Og({initialNodes:t,initialEdges:r,defaultNodes:o,defaultEdges:l,initialWidth:u,initialHeight:c,initialMinZoom:f,initialMaxZoom:h,initialFitViewOptions:p,fitView:y,nodeOrigin:m,nodeExtent:v,zIndexMode:g,children:w}){const[S]=G.useState(()=>i_({nodes:t,edges:r,defaultNodes:o,defaultEdges:l,width:u,height:c,fitView:y,minZoom:f,maxZoom:h,fitViewOptions:p,nodeOrigin:m,nodeExtent:v,zIndexMode:g}));return P.jsx(y1,{value:S,children:P.jsx(U1,{children:P.jsx(iS,{children:w})})})}function o_({children:t,nodes:r,edges:o,defaultNodes:l,defaultEdges:u,width:c,height:f,fitView:h,fitViewOptions:p,minZoom:y,maxZoom:m,nodeOrigin:v,nodeExtent:g,zIndexMode:w}){return G.useContext(yl)?P.jsx(P.Fragment,{children:t}):P.jsx(Og,{initialNodes:r,initialEdges:o,defaultNodes:l,defaultEdges:u,initialWidth:c,initialHeight:f,fitView:h,initialFitViewOptions:p,initialMinZoom:y,initialMaxZoom:m,nodeOrigin:v,nodeExtent:g,zIndexMode:w,children:t})}const s_={width:"100%",height:"100%",overflow:"hidden",position:"relative",zIndex:0};function l_({nodes:t,edges:r,defaultNodes:o,defaultEdges:l,className:u,nodeTypes:c,edgeTypes:f,onNodeClick:h,onEdgeClick:p,onInit:y,onMove:m,onMoveStart:v,onMoveEnd:g,onConnect:w,onConnectStart:S,onConnectEnd:M,onClickConnectStart:N,onClickConnectEnd:E,onNodeMouseEnter:R,onNodeMouseMove:_,onNodeMouseLeave:I,onNodeContextMenu:W,onNodeDoubleClick:$,onNodeDragStart:V,onNodeDrag:X,onNodeDragStop:K,onNodesDelete:te,onEdgesDelete:Z,onDelete:ee,onSelectionChange:J,onSelectionDragStart:k,onSelectionDrag:B,onSelectionDragStop:H,onSelectionContextMenu:U,onSelectionStart:A,onSelectionEnd:j,onBeforeDelete:F,connectionMode:C,connectionLineType:z=Wn.Bezier,connectionLineStyle:ne,connectionLineComponent:ie,connectionLineContainerStyle:ce,deleteKeyCode:O="Backspace",selectionKeyCode:re="Shift",selectionOnDrag:Q=!1,selectionMode:le=lo.Full,panActivationKeyCode:pe="Space",multiSelectionKeyCode:we=ao()?"Meta":"Control",zoomActivationKeyCode:ve=ao()?"Meta":"Control",snapToGrid:me,snapGrid:ke,onlyRenderVisibleElements:Ce=!1,selectNodesOnDrag:Me,nodesDraggable:Re,autoPanOnNodeFocus:ot,nodesConnectable:ut,nodesFocusable:Je,nodeOrigin:et=cg,edgesFocusable:sn,edgesReconnectable:Gt,elementsSelectable:Kt=!0,defaultViewport:at=T1,minZoom:Ct=.5,maxZoom:yt=2,translateExtent:Mt=so,preventScrolling:wr=!0,nodeExtent:ln,defaultMarkerColor:Yn="#b1b1b7",zoomOnScroll:Sl=!0,zoomOnPinch:xo=!0,panOnScroll:wo=!1,panOnScrollSpeed:_l=.5,panOnScrollMode:si=pr.Free,zoomOnDoubleClick:li=!0,panOnDrag:ui=!0,onPaneClick:ai,onPaneMouseEnter:ci,onPaneMouseMove:wn,onPaneMouseLeave:Sn,onPaneScroll:So,onPaneContextMenu:_o,paneClickDistance:Eo=1,nodeClickDistance:ko=0,children:No,onReconnect:fi,onReconnectStart:Co,onReconnectEnd:Xn,onEdgeContextMenu:di,onEdgeDoubleClick:Qn,onEdgeMouseEnter:El,onEdgeMouseMove:Gn,onEdgeMouseLeave:Sr,reconnectRadius:_r=10,onNodesChange:hi,onEdgesChange:kl,noDragClassName:Nl="nodrag",noWheelClassName:Cl="nowheel",noPanClassName:qt="nopan",fitView:pi,fitViewOptions:gi,connectOnClick:Ml,attributionPosition:Mo,proOptions:Po,defaultEdgeOptions:Io,elevateNodesOnSelect:To=!0,elevateEdgesOnSelect:Pl=!1,disableKeyboardA11y:zo=!1,autoPanOnConnect:Oe,autoPanOnNodeDrag:Il,autoPanOnSelection:mi=!0,autoPanSpeed:Ro,connectionRadius:Er,isValidConnection:Tl,onError:Lo,style:kr,id:vt,nodeDragThreshold:zl,connectionDragThreshold:xt,viewport:Rl,onViewportChange:Ll,width:jl,height:Nr,colorMode:Cr="light",debug:Kn,onScroll:un,ariaLabelConfig:Al,zIndexMode:jo="basic",...yi},Ao){const qn=vt||"1",Zn=j1(Cr),$l=G.useCallback(Mr=>{Mr.currentTarget.scrollTo({top:0,left:0,behavior:"instant"}),un==null||un(Mr)},[un]);return P.jsx("div",{"data-testid":"rf__wrapper",...yi,onScroll:$l,style:{...kr,...s_},ref:Ao,className:be(["react-flow",u,Zn]),id:vt,role:"application",children:P.jsxs(o_,{nodes:t,edges:r,width:jl,height:Nr,fitView:pi,fitViewOptions:gi,minZoom:Ct,maxZoom:yt,nodeOrigin:et,nodeExtent:ln,zIndexMode:jo,children:[P.jsx(L1,{nodes:t,edges:r,defaultNodes:o,defaultEdges:l,onConnect:w,onConnectStart:S,onConnectEnd:M,onClickConnectStart:N,onClickConnectEnd:E,nodesDraggable:Re,autoPanOnNodeFocus:ot,nodesConnectable:ut,nodesFocusable:Je,edgesFocusable:sn,edgesReconnectable:Gt,elementsSelectable:Kt,elevateNodesOnSelect:To,elevateEdgesOnSelect:Pl,minZoom:Ct,maxZoom:yt,nodeExtent:ln,onNodesChange:hi,onEdgesChange:kl,snapToGrid:me,snapGrid:ke,connectionMode:C,translateExtent:Mt,connectOnClick:Ml,defaultEdgeOptions:Io,fitView:pi,fitViewOptions:gi,onNodesDelete:te,onEdgesDelete:Z,onDelete:ee,onNodeDragStart:V,onNodeDrag:X,onNodeDragStop:K,onSelectionDrag:B,onSelectionDragStart:k,onSelectionDragStop:H,onMove:m,onMoveStart:v,onMoveEnd:g,noPanClassName:qt,nodeOrigin:et,rfId:qn,autoPanOnConnect:Oe,autoPanOnNodeDrag:Il,autoPanSpeed:Ro,onError:Lo,connectionRadius:Er,isValidConnection:Tl,selectNodesOnDrag:Me,nodeDragThreshold:zl,connectionDragThreshold:xt,onBeforeDelete:F,debug:Kn,ariaLabelConfig:Al,zIndexMode:jo}),P.jsx(n_,{onInit:y,onNodeClick:h,onEdgeClick:p,onNodeMouseEnter:R,onNodeMouseMove:_,onNodeMouseLeave:I,onNodeContextMenu:W,onNodeDoubleClick:$,nodeTypes:c,edgeTypes:f,connectionLineType:z,connectionLineStyle:ne,connectionLineComponent:ie,connectionLineContainerStyle:ce,selectionKeyCode:re,selectionOnDrag:Q,selectionMode:le,deleteKeyCode:O,multiSelectionKeyCode:we,panActivationKeyCode:pe,zoomActivationKeyCode:ve,onlyRenderVisibleElements:Ce,defaultViewport:at,translateExtent:Mt,minZoom:Ct,maxZoom:yt,preventScrolling:wr,zoomOnScroll:Sl,zoomOnPinch:xo,zoomOnDoubleClick:li,panOnScroll:wo,panOnScrollSpeed:_l,panOnScrollMode:si,panOnDrag:ui,autoPanOnSelection:mi,onPaneClick:ai,onPaneMouseEnter:ci,onPaneMouseMove:wn,onPaneMouseLeave:Sn,onPaneScroll:So,onPaneContextMenu:_o,paneClickDistance:Eo,nodeClickDistance:ko,onSelectionContextMenu:U,onSelectionStart:A,onSelectionEnd:j,onReconnect:fi,onReconnectStart:Co,onReconnectEnd:Xn,onEdgeContextMenu:di,onEdgeDoubleClick:Qn,onEdgeMouseEnter:El,onEdgeMouseMove:Gn,onEdgeMouseLeave:Sr,reconnectRadius:_r,defaultMarkerColor:Yn,noDragClassName:Nl,noWheelClassName:Cl,noPanClassName:qt,rfId:qn,disableKeyboardA11y:zo,nodeExtent:ln,viewport:Rl,onViewportChange:Ll,nodesDraggable:Re}),P.jsx(I1,{onSelectionChange:J}),No,P.jsx(k1,{proOptions:Po,position:Mo}),P.jsx(E1,{rfId:qn,disableKeyboardA11y:zo})]})})}var u_=dg(l_);function a_({dimensions:t,lineWidth:r,variant:o,className:l}){return P.jsx("path",{strokeWidth:r,d:`M${t[0]/2} 0 V${t[1]} M0 ${t[1]/2} H${t[0]}`,className:be(["react-flow__background-pattern",o,l])})}function c_({radius:t,className:r}){return P.jsx("circle",{cx:t,cy:t,r:t,className:be(["react-flow__background-pattern","dots",r])})}var bn;(function(t){t.Lines="lines",t.Dots="dots",t.Cross="cross"})(bn||(bn={}));const f_={[bn.Dots]:1,[bn.Lines]:1,[bn.Cross]:6},d_=t=>({transform:t.transform,patternId:`pattern-${t.rfId}`});function Fg({id:t,variant:r=bn.Dots,gap:o=20,size:l,lineWidth:u=1,offset:c=0,color:f,bgColor:h,style:p,className:y,patternClassName:m}){const v=G.useRef(null),{transform:g,patternId:w}=Ie(d_,Ve),S=l||f_[r],M=r===bn.Dots,N=r===bn.Cross,E=Array.isArray(o)?o:[o,o],R=[E[0]*g[2]||1,E[1]*g[2]||1],_=S*g[2],I=Array.isArray(c)?c:[c,c],W=N?[_,_]:R,$=[I[0]*g[2]+W[0]/2,I[1]*g[2]+W[1]/2],V=`${w}${t||""}`;return P.jsxs("svg",{className:be(["react-flow__background",y]),style:{...p,...xl,"--xy-background-color-props":h,"--xy-background-pattern-color-props":f},ref:v,"data-testid":"rf__background",children:[P.jsx("pattern",{id:V,x:g[0]%R[0],y:g[1]%R[1],width:R[0],height:R[1],patternUnits:"userSpaceOnUse",patternTransform:`translate(-${$[0]},-${$[1]})`,children:M?P.jsx(c_,{radius:_/2,className:m}):P.jsx(a_,{dimensions:W,lineWidth:u,variant:r,className:m})}),P.jsx("rect",{x:"0",y:"0",width:"100%",height:"100%",fill:`url(#${V})`})]})}Fg.displayName="Background";const h_=G.memo(Fg);function p_(){return P.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",children:P.jsx("path",{d:"M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z"})})}function g_(){return P.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 5",children:P.jsx("path",{d:"M0 0h32v4.2H0z"})})}function m_(){return P.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 30",children:P.jsx("path",{d:"M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z"})})}function y_(){return P.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 25 32",children:P.jsx("path",{d:"M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z"})})}function v_(){return P.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 25 32",children:P.jsx("path",{d:"M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z"})})}function bs({children:t,className:r,...o}){return P.jsx("button",{type:"button",className:be(["react-flow__controls-button",r]),...o,children:t})}const x_=t=>({isInteractive:t.nodesDraggable||t.nodesConnectable||t.elementsSelectable,minZoomReached:t.transform[2]<=t.minZoom,maxZoomReached:t.transform[2]>=t.maxZoom,ariaLabelConfig:t.ariaLabelConfig});function Hg({style:t,showZoom:r=!0,showFitView:o=!0,showInteractive:l=!0,fitViewOptions:u,onZoomIn:c,onZoomOut:f,onFitView:h,onInteractiveChange:p,className:y,children:m,position:v="bottom-left",orientation:g="vertical","aria-label":w}){const S=$e(),{isInteractive:M,minZoomReached:N,maxZoomReached:E,ariaLabelConfig:R}=Ie(x_,Ve),{zoomIn:_,zoomOut:I,fitView:W}=ac(),$=()=>{_(),c==null||c()},V=()=>{I(),f==null||f()},X=()=>{W(u),h==null||h()},K=()=>{S.setState({nodesDraggable:!M,nodesConnectable:!M,elementsSelectable:!M}),p==null||p(!M)},te=g==="horizontal"?"horizontal":"vertical";return P.jsxs(vl,{className:be(["react-flow__controls",te,y]),position:v,style:t,"data-testid":"rf__controls","aria-label":w??R["controls.ariaLabel"],children:[r&&P.jsxs(P.Fragment,{children:[P.jsx(bs,{onClick:$,className:"react-flow__controls-zoomin",title:R["controls.zoomIn.ariaLabel"],"aria-label":R["controls.zoomIn.ariaLabel"],disabled:E,children:P.jsx(p_,{})}),P.jsx(bs,{onClick:V,className:"react-flow__controls-zoomout",title:R["controls.zoomOut.ariaLabel"],"aria-label":R["controls.zoomOut.ariaLabel"],disabled:N,children:P.jsx(g_,{})})]}),o&&P.jsx(bs,{className:"react-flow__controls-fitview",onClick:X,title:R["controls.fitView.ariaLabel"],"aria-label":R["controls.fitView.ariaLabel"],children:P.jsx(m_,{})}),l&&P.jsx(bs,{className:"react-flow__controls-interactive",onClick:K,title:R["controls.interactive.ariaLabel"],"aria-label":R["controls.interactive.ariaLabel"],children:M?P.jsx(v_,{}):P.jsx(y_,{})}),m]})}Hg.displayName="Controls";const w_=G.memo(Hg);function S_({id:t,x:r,y:o,width:l,height:u,style:c,color:f,strokeColor:h,strokeWidth:p,className:y,borderRadius:m,shapeRendering:v,selected:g,onClick:w}){const{background:S,backgroundColor:M}=c||{},N=f||S||M;return P.jsx("rect",{className:be(["react-flow__minimap-node",{selected:g},y]),x:r,y:o,rx:m,ry:m,width:l,height:u,style:{fill:N,stroke:h,strokeWidth:p},shapeRendering:v,onClick:w?E=>w(E,t):void 0})}const __=G.memo(S_),E_=t=>t.nodes.map(r=>r.id),Ta=t=>t instanceof Function?t:()=>t;function k_({nodeStrokeColor:t,nodeColor:r,nodeClassName:o="",nodeBorderRadius:l=5,nodeStrokeWidth:u,nodeComponent:c=__,onClick:f}){const h=Ie(E_,Ve),p=Ta(r),y=Ta(t),m=Ta(o),v=typeof window>"u"||window.chrome?"crispEdges":"geometricPrecision";return P.jsx(P.Fragment,{children:h.map(g=>P.jsx(C_,{id:g,nodeColorFunc:p,nodeStrokeColorFunc:y,nodeClassNameFunc:m,nodeBorderRadius:l,nodeStrokeWidth:u,NodeComponent:c,onClick:f,shapeRendering:v},g))})}function N_({id:t,nodeColorFunc:r,nodeStrokeColorFunc:o,nodeClassNameFunc:l,nodeBorderRadius:u,nodeStrokeWidth:c,shapeRendering:f,NodeComponent:h,onClick:p}){const{node:y,x:m,y:v,width:g,height:w}=Ie(S=>{const M=S.nodeLookup.get(t);if(!M)return{node:void 0,x:0,y:0,width:0,height:0};const N=M.internals.userNode,{x:E,y:R}=M.internals.positionAbsolute,{width:_,height:I}=Qt(N);return{node:N,x:E,y:R,width:_,height:I}},Ve);return!y||y.hidden||!Hp(y)?null:P.jsx(h,{x:m,y:v,width:g,height:w,style:y.style,selected:!!y.selected,className:l(y),color:r(y),borderRadius:u,strokeColor:o(y),strokeWidth:c,shapeRendering:f,onClick:p,id:y.id})}const C_=G.memo(N_);var M_=G.memo(k_);const P_=200,I_=150,T_=t=>!t.hidden,z_=t=>{const r={x:-t.transform[0]/t.transform[2],y:-t.transform[1]/t.transform[2],width:t.width/t.transform[2],height:t.height/t.transform[2]};return{viewBB:r,boundingRect:t.nodeLookup.size>0?Dp(mo(t.nodeLookup,{filter:T_}),r):r,rfId:t.rfId,panZoom:t.panZoom,translateExtent:t.translateExtent,flowWidth:t.width,flowHeight:t.height,ariaLabelConfig:t.ariaLabelConfig}},qh=(t,r)=>t.x===r.x&&t.y===r.y&&t.width===r.width&&t.height===r.height,R_=(t,r)=>qh(t.viewBB,r.viewBB)&&qh(t.boundingRect,r.boundingRect)&&t.rfId===r.rfId&&t.panZoom===r.panZoom&&t.translateExtent===r.translateExtent&&t.flowWidth===r.flowWidth&&t.flowHeight===r.flowHeight&&t.ariaLabelConfig===r.ariaLabelConfig,L_="react-flow__minimap-desc";function Vg({style:t,className:r,nodeStrokeColor:o,nodeColor:l,nodeClassName:u="",nodeBorderRadius:c=5,nodeStrokeWidth:f,nodeComponent:h,bgColor:p,maskColor:y,maskStrokeColor:m,maskStrokeWidth:v,position:g="bottom-right",onClick:w,onNodeClick:S,pannable:M=!1,zoomable:N=!1,ariaLabel:E,inversePan:R,zoomStep:_=1,offsetScale:I=5}){const W=$e(),$=G.useRef(null),{boundingRect:V,viewBB:X,rfId:K,panZoom:te,translateExtent:Z,flowWidth:ee,flowHeight:J,ariaLabelConfig:k}=Ie(z_,R_),B=(t==null?void 0:t.width)??P_,H=(t==null?void 0:t.height)??I_,U=V.width/B,A=V.height/H,j=Math.max(U,A),F=j*B,C=j*H,z=I*j,ne=V.x-(F-V.width)/2-z,ie=V.y-(C-V.height)/2-z,ce=F+z*2,O=C+z*2,re=`${L_}-${K}`,Q=G.useRef(0),le=G.useRef();Q.current=j,G.useEffect(()=>{if($.current&&te)return le.current=bw({domNode:$.current,panZoom:te,getTransform:()=>W.getState().transform,getViewScale:()=>Q.current}),()=>{var me;(me=le.current)==null||me.destroy()}},[te]),G.useEffect(()=>{var me;(me=le.current)==null||me.update({translateExtent:Z,width:ee,height:J,inversePan:R,pannable:M,zoomStep:_,zoomable:N})},[M,N,R,_,Z,ee,J]);const pe=w?me=>{var Me;const[ke,Ce]=((Me=le.current)==null?void 0:Me.pointer(me))||[0,0];w(me,{x:ke,y:Ce})}:void 0,we=S?G.useCallback((me,ke)=>{const Ce=W.getState().nodeLookup.get(ke).internals.userNode;S(me,Ce)},[]):void 0,ve=E??k["minimap.ariaLabel"];return P.jsx(vl,{position:g,style:{...t,"--xy-minimap-background-color-props":typeof p=="string"?p:void 0,"--xy-minimap-mask-background-color-props":typeof y=="string"?y:void 0,"--xy-minimap-mask-stroke-color-props":typeof m=="string"?m:void 0,"--xy-minimap-mask-stroke-width-props":typeof v=="number"?v*j:void 0,"--xy-minimap-node-background-color-props":typeof l=="string"?l:void 0,"--xy-minimap-node-stroke-color-props":typeof o=="string"?o:void 0,"--xy-minimap-node-stroke-width-props":typeof f=="number"?f:void 0},className:be(["react-flow__minimap",r]),"data-testid":"rf__minimap",children:P.jsxs("svg",{width:B,height:H,viewBox:`${ne} ${ie} ${ce} ${O}`,className:"react-flow__minimap-svg",role:"img","aria-labelledby":re,ref:$,onClick:pe,children:[ve&&P.jsx("title",{id:re,children:ve}),P.jsx(M_,{onClick:we,nodeColor:l,nodeStrokeColor:o,nodeBorderRadius:c,nodeClassName:u,nodeStrokeWidth:f,nodeComponent:h}),P.jsx("path",{className:"react-flow__minimap-mask",d:`M${ne-z},${ie-z}h${ce+z*2}v${O+z*2}h${-ce-z*2}z + M${X.x},${X.y}h${X.width}v${X.height}h${-X.width}z`,fillRule:"evenodd",pointerEvents:"none"})]})})}Vg.displayName="MiniMap";const j_=G.memo(Vg),A_=t=>r=>t?`${Math.max(1/r.transform[2],1)}`:void 0,$_={[oi.Line]:"right",[oi.Handle]:"bottom-right"};function D_({nodeId:t,position:r,variant:o=oi.Handle,className:l,style:u=void 0,children:c,color:f,minWidth:h=10,minHeight:p=10,maxWidth:y=Number.MAX_VALUE,maxHeight:m=Number.MAX_VALUE,keepAspectRatio:v=!1,resizeDirection:g,autoScale:w=!0,shouldResize:S,onResizeStart:M,onResize:N,onResizeEnd:E}){const R=yg(),_=typeof t=="string"?t:R,I=$e(),W=G.useRef(null),$=o===oi.Handle,V=Ie(G.useCallback(A_($&&w),[$,w]),Ve),X=G.useRef(null),K=r??$_[o];G.useEffect(()=>{if(!(!W.current||!_))return X.current||(X.current=i1({domNode:W.current,nodeId:_,getStoreItems:()=>{const{nodeLookup:Z,transform:ee,snapGrid:J,snapToGrid:k,nodeOrigin:B,domNode:H}=I.getState();return{nodeLookup:Z,transform:ee,snapGrid:J,snapToGrid:k,nodeOrigin:B,paneDomNode:H}},onChange:(Z,ee)=>{const{triggerNodeChanges:J,nodeLookup:k,parentLookup:B,nodeOrigin:H}=I.getState(),U=[],A={x:Z.x,y:Z.y},j=k.get(_);if(j&&j.expandParent&&j.parentId){const F=j.origin??H,C=Z.width??j.measured.width??0,z=Z.height??j.measured.height??0,ne={id:j.id,parentId:j.parentId,rect:{width:C,height:z,...Vp({x:Z.x??j.position.x,y:Z.y??j.position.y},{width:C,height:z},j.parentId,k,F)}},ie=uc([ne],k,B,H);U.push(...ie),A.x=Z.x?Math.max(F[0]*C,Z.x):void 0,A.y=Z.y?Math.max(F[1]*z,Z.y):void 0}if(A.x!==void 0&&A.y!==void 0){const F={id:_,type:"position",position:{...A}};U.push(F)}if(Z.width!==void 0&&Z.height!==void 0){const C={id:_,type:"dimensions",resizing:!0,setAttributes:g?g==="horizontal"?"width":"height":!0,dimensions:{width:Z.width,height:Z.height}};U.push(C)}for(const F of ee){const C={...F,type:"position"};U.push(C)}J(U)},onEnd:({width:Z,height:ee})=>{const J={id:_,type:"dimensions",resizing:!1,dimensions:{width:Z,height:ee}};I.getState().triggerNodeChanges([J])}})),X.current.update({controlPosition:K,boundaries:{minWidth:h,minHeight:p,maxWidth:y,maxHeight:m},keepAspectRatio:v,resizeDirection:g,onResizeStart:M,onResize:N,onResizeEnd:E,shouldResize:S}),()=>{var Z;(Z=X.current)==null||Z.destroy()}},[K,h,p,y,m,v,M,N,E,S]);const te=K.split("-");return P.jsx("div",{className:be(["react-flow__resize-control","nodrag",...te,o,l]),ref:W,style:{...u,scale:V,...f&&{[$?"backgroundColor":"borderColor"]:f}},children:c})}G.memo(D_);const O_={"arch.context":0,"django.app":0,"django.route":1,"django.url_name":1,"django.view":2,"django.viewset_action":2,"django.permission":2,"django.serializer":3,"django.serializer_field":4,"django.service":4,"django.model":5,"django.field":6,"django.relation":6,"django.task":7,"django.receiver":7,"django.signal":7,"django.test":7,"django.migration_op":7,"django.admin":7,"openapi.path":8,"react.api_client":9,"react.query_key":10,"react.hook":10,"react.feature":10,"react.route":11,"react.page":11,"react.component":12,"react.form_schema":13,"react.test":13,"react.context":12};function F_(t){return O_[t]??8}function H_(t){const r=new Map;for(const l of t){const u=F_(l.type),c=r.get(u)??[];c.push(l),r.set(u,c)}const o=new Map;for(const[l,u]of r)u.sort((c,f)=>c.name.localeCompare(f.name)),u.forEach((c,f)=>{o.set(c.id,{x:l*240,y:f*92})});return o}const V_={cheap:"#4a5568",expensive:"#f4a261",critical:"#e85d04"};function B_({data:t}){return P.jsxs("div",{className:"lp-node",children:[P.jsx("div",{className:"t",children:t.type.split(".").pop()}),P.jsx("div",{className:"n",children:t.name})]})}const U_={load:B_};function za({nodes:t,edges:r}){const o=H_(t),l=t.map(c=>({id:c.id,type:"load",position:o.get(c.id)??{x:0,y:0},data:{name:c.name,type:c.type,file:c.file_path}})),u=r.filter(c=>t.some(f=>f.id===c.src)&&t.some(f=>f.id===c.dst)).map(c=>({id:c.id,source:c.src,target:c.dst,animated:c.weight==="critical",style:{stroke:V_[c.weight]||"#4a5568",strokeWidth:c.weight==="critical"?2.4:1.2,strokeDasharray:c.confidence<.8?"6 4":void 0},label:c.type.replaceAll("_"," "),labelStyle:{fill:"#8b9bb0",fontSize:9}}));return P.jsx(Og,{children:P.jsxs(u_,{nodes:l,edges:u,nodeTypes:U_,fitView:!0,minZoom:.2,"data-testid":"impact-graph",children:[P.jsx(h_,{}),P.jsx(j_,{pannable:!0,zoomable:!0}),P.jsx(w_,{})]})})}function W_(){var ce;const[t,r]=G.useState("review"),[o,l]=G.useState(localStorage.getItem("loadpath.repo")||""),[u,c]=G.useState("HEAD~1"),[f,h]=G.useState("HEAD"),[p,y]=G.useState(null),[m,v]=G.useState(null),[g,w]=G.useState([]),[S,M]=G.useState("review"),[N,E]=G.useState(""),[R,_]=G.useState(""),[I,W]=G.useState({}),[$,V]=G.useState([]),[X,K]=G.useState(""),[te,Z]=G.useState("github"),[ee,J]=G.useState(""),k=G.useRef(o);k.current=o,G.useEffect(()=>{Vt.settings().then(W).catch(()=>{}),Vt.repos().then(O=>w(O.repos)).catch(()=>{})},[]),G.useEffect(()=>{if(t!=="architecture"||!o)return;const O=o;let re=!1;return Vt.architecture(O).then(Q=>{!re&&k.current===O&&v(Q)}).catch(()=>{}),()=>{re=!0}},[t,o]);const B=O=>{l(O),localStorage.setItem("loadpath.repo",O)},H=async(O=o)=>{if(!O)return null;const re=await Vt.architecture(O);return k.current===O&&v(re),re},U=async()=>{E(""),_("Tracing load path…"),B(o);try{const O=await Vt.review(o,u,f,!0);y(O),M("review"),r("review"),await Vt.repos().then(re=>w(re.repos)).catch(()=>{}),await H(o)}catch(O){E(O instanceof Error?O.message:String(O))}finally{_("")}},A=async(O=!0)=>{E(""),_(O?"Indexing…":"Full reindex…"),B(o);try{await Vt.index(o,O);const re=await H(o);await Vt.repos().then(Q=>w(Q.repos)).catch(()=>{}),re!=null&&re.indexed&&(M("architecture"),r("architecture"))}catch(re){E(re instanceof Error?re.message:String(re))}finally{_("")}},j=async()=>{E(""),_("Fetching pull requests…");try{const O=await Vt.prs(te,X);V(O.pull_requests)}catch(O){E(O instanceof Error?O.message:String(O))}finally{_("")}},F=async O=>{O.preventDefault();const re=new FormData(O.currentTarget),Q={github_token:String(re.get("github_token")||""),bitbucket_token:String(re.get("bitbucket_token")||""),bitbucket_username:String(re.get("bitbucket_username")||""),ai_provider:String(re.get("ai_provider")||"none"),ai_api_key:String(re.get("ai_api_key")||""),ai_model:String(re.get("ai_model")||""),ai_base_url:String(re.get("ai_base_url")||""),workspaces:g.length?g.map(le=>({path:le.path,name:le.name})):o?[{path:o,name:o.split(/[\\/]/).pop()}]:[]};W(await Vt.saveSettings(Q))},C=async()=>{if(p){_("Residual analysis…");try{const O=await Vt.residual(p);J(O.note)}catch(O){E(O instanceof Error?O.message:String(O))}finally{_("")}}},z=G.useMemo(()=>S==="architecture"?(m==null?void 0:m.nodes)??[]:(p==null?void 0:p.nodes)??[],[S,m,p]),ne=G.useMemo(()=>S==="architecture"?(m==null?void 0:m.edges)??[]:(p==null?void 0:p.edges)??[],[S,m,p]),ie=p!=null&&p.index?`${p.index.counts.nodes} nodes / ${p.index.counts.edges} edges · ${p.index.incremental?"incremental":"full"}`:m!=null&&m.indexed?`${m.counts.nodes} nodes / ${m.counts.edges} edges indexed`:"Not indexed";return P.jsxs("div",{className:"app",children:[P.jsxs("nav",{className:"rail","data-testid":"rail",children:[P.jsx("div",{className:"brand",children:"Loadpath"}),P.jsx("button",{"data-testid":"tab-review",className:t==="review"?"active":"",onClick:()=>r("review"),children:"Review"}),P.jsx("button",{"data-testid":"tab-architecture",className:t==="architecture"?"active":"",onClick:()=>r("architecture"),children:"Architecture"}),P.jsx("button",{"data-testid":"tab-graph",className:t==="graph"?"active":"",onClick:()=>r("graph"),children:"Impact graph"}),P.jsx("button",{"data-testid":"tab-prs",className:t==="prs"?"active":"",onClick:()=>r("prs"),children:"Pull requests"}),P.jsx("button",{"data-testid":"tab-settings",className:t==="settings"?"active":"",onClick:()=>r("settings"),children:"Settings"}),P.jsx("div",{style:{flex:1}}),P.jsx("div",{className:"muted",children:R||ie})]}),P.jsxs("div",{className:"main",children:[P.jsxs("div",{className:"topbar","data-testid":"topbar",children:[g.length>0?P.jsxs("select",{"data-testid":"workspace-select",value:g.some(O=>O.path===o)?o:"",onChange:O=>{O.target.value&&B(O.target.value)},children:[P.jsx("option",{value:"",children:"Indexed repos…"}),g.map(O=>P.jsxs("option",{value:O.path,children:[O.name,O.indexed?` (${O.counts.nodes})`:""]},O.path))]}):null,P.jsx("input",{"data-testid":"repo-path",className:"path",placeholder:"Local monorepo path",value:o,onChange:O=>l(O.target.value)}),P.jsx("input",{"data-testid":"base-ref",value:u,onChange:O=>c(O.target.value),placeholder:"base"}),P.jsx("input",{"data-testid":"head-ref",value:f,onChange:O=>h(O.target.value),placeholder:"head"}),P.jsx("button",{"data-testid":"btn-index",onClick:()=>A(!0),children:"Index"}),P.jsx("button",{"data-testid":"btn-review",className:"btn primary",onClick:U,children:"Review"})]}),N?P.jsx("div",{className:"error",children:N}):null,t==="review"&&P.jsxs("div",{className:"content","data-testid":"review-layout",children:[P.jsx("aside",{className:"brief","data-testid":"brief",children:p?P.jsxs(P.Fragment,{children:[P.jsxs("div",{className:`level ${p.confidence.level}`,children:[p.confidence.level.toUpperCase()," — ",p.title]}),p.low_risk?P.jsx("span",{className:"chip",children:"loadpath:low-risk"}):null,p.change_kinds.map(O=>P.jsx("span",{className:"chip",children:O.replaceAll("_"," ")},O)),P.jsx("pre",{className:"headline",children:p.headline}),p.index?P.jsxs(P.Fragment,{children:[P.jsx("div",{className:"kicker",children:"Index"}),P.jsxs("div",{className:"muted",children:["Walked ",p.index.counts.nodes," nodes / ",p.index.counts.edges," edges",p.index.reindexed?" after an incremental refresh":" from the existing index","."]})]}):null,P.jsx("div",{className:"kicker",children:"Read this"}),p.read_order.map(O=>P.jsxs("div",{children:[P.jsx("span",{className:"file",children:O.path}),P.jsx("div",{className:"muted",children:O.why})]},O.path)),P.jsx("div",{className:"kicker",children:"Clusters"}),p.clusters.map(O=>P.jsxs("div",{className:"muted",children:[P.jsx("strong",{children:O.title})," — ",O.files.join(", ")]},O.id)),P.jsx("div",{className:"kicker",children:"Architecture"}),p.findings.filter(O=>!O.waived).length===0?P.jsx("div",{className:"muted",children:p.architecture_note}):p.findings.filter(O=>!O.waived).map(O=>P.jsxs("div",{className:"muted",children:[P.jsx("span",{className:`chip ${O.severity}`,children:O.severity}),O.message]},O.rule+O.message)),P.jsx("div",{className:"kicker",children:"Residual (AI only here)"}),p.residuals.map(O=>P.jsx("div",{className:"muted",children:O},O)),P.jsx("button",{className:"btn",onClick:C,style:{marginTop:12},children:"Ask configured model"}),ee?P.jsx("pre",{className:"headline",children:ee}):null,P.jsx("div",{className:"kicker",children:"Reviewers"}),P.jsx("div",{className:"muted",children:p.suggested_reviewers.join(", ")||"—"})]}):P.jsxs("p",{className:"muted",children:["Index a Django + React monorepo with ",P.jsx("code",{children:"loadpath.yml"}),", inspect architecture, then review a git range against that graph."]})}),P.jsx("div",{className:"graph-wrap","data-testid":"review-graph",children:p?P.jsx(za,{nodes:p.nodes,edges:p.edges}):null})]}),t==="architecture"&&P.jsxs("div",{className:"content","data-testid":"architecture-panel",children:[P.jsx("aside",{className:"brief","data-testid":"architecture-brief",children:m!=null&&m.indexed?P.jsxs(P.Fragment,{children:[P.jsxs("div",{className:"level high",children:["INDEXED — ",m.counts.nodes," nodes"]}),P.jsxs("span",{className:"chip",children:[m.counts.edges," edges"]}),m.has_config?P.jsx("span",{className:"chip",children:"loadpath.yml"}):null,P.jsxs("div",{className:"muted",style:{marginTop:8},children:[m.indexed_at?`Last index ${m.indexed_at}`:"Indexed",m.incremental?" · incremental":" · full"]}),P.jsx("div",{className:"kicker",children:"Bounded contexts"}),Object.values(m.contexts).map(O=>P.jsxs("div",{className:"muted",children:[P.jsx("strong",{children:O.name})," — ",(O.django_apps||[]).join(", ")||"no apps"," ·"," ",(O.owners||[]).join(", ")||"unowned"]},O.name)),P.jsx("div",{className:"kicker",children:"Rules"}),(m.rules||[]).map(O=>P.jsx("div",{className:"muted",children:O},O)),P.jsx("div",{className:"kicker",children:"Findings on the indexed graph"}),m.findings.filter(O=>!O.waived).length===0?P.jsx("div",{className:"muted",children:"No architecture rule hits on the full graph."}):m.findings.filter(O=>!O.waived).map(O=>P.jsxs("div",{className:"muted",children:[P.jsx("span",{className:`chip ${O.severity}`,children:O.severity}),O.message]},O.rule+O.message)),P.jsx("div",{className:"kicker",children:"Types"}),P.jsx("div",{className:"muted",children:Object.entries(m.type_counts||{}).sort((O,re)=>re[1]-O[1]).slice(0,12).map(([O,re])=>`${O.split(".").pop()} ${re}`).join(" · ")}),P.jsx("button",{className:"btn",style:{marginTop:12},onClick:()=>A(!1),"data-testid":"btn-full-reindex",children:"Full reindex"}),P.jsx("button",{className:"btn primary",style:{marginTop:8},onClick:U,children:"Review against this index"})]}):P.jsx("p",{className:"muted","data-testid":"architecture-empty",children:"Index this repo to build the architecture graph. Review then walks that same graph for a git range — it does not start from a hunk list."})}),P.jsx("div",{className:"graph-wrap","data-testid":"architecture-graph",children:m!=null&&m.indexed?P.jsx(za,{nodes:m.nodes,edges:m.edges}):null})]}),t==="graph"&&P.jsxs("div",{className:"graph-wrap","data-testid":"graph-full",style:{height:"100%",display:"flex",flexDirection:"column"},children:[P.jsxs("div",{className:"graph-modes",children:[P.jsx("button",{"data-testid":"graph-mode-review",className:S==="review"?"active":"",onClick:()=>M("review"),children:"This review"}),P.jsx("button",{"data-testid":"graph-mode-architecture",className:S==="architecture"?"active":"",onClick:()=>M("architecture"),children:"Indexed architecture"})]}),z.length?P.jsx("div",{style:{flex:1,minHeight:0},children:P.jsx(za,{nodes:z,edges:ne})}):P.jsx("p",{className:"muted",children:"Index the repo or run a review first."})]}),t==="prs"&&P.jsxs("div",{className:"pr-list","data-testid":"pr-list",children:[P.jsxs("div",{className:"topbar",style:{border:0,padding:0,marginBottom:12},children:[P.jsxs("select",{"data-testid":"pr-provider",value:te,onChange:O=>Z(O.target.value),children:[P.jsx("option",{value:"github",children:"GitHub"}),P.jsx("option",{value:"bitbucket",children:"Bitbucket"})]}),P.jsx("input",{"data-testid":"pr-repo",className:"path",placeholder:"owner/repo",value:X,onChange:O=>K(O.target.value)}),P.jsx("button",{"data-testid":"btn-list-prs",onClick:j,children:"List PRs"})]}),$.map(O=>P.jsxs("article",{className:"pr","data-testid":`pr-${O.number}`,children:[P.jsxs("h3",{children:["#",O.number," ",O.title]}),P.jsxs("div",{className:"muted",children:[O.author," · ",O.source_branch," → ",O.target_branch," · ",O.provider]}),P.jsxs("a",{href:O.url,target:"_blank",rel:"noreferrer",children:["Open on ",O.provider]}),P.jsx("div",{children:P.jsx("button",{className:"btn",onClick:()=>{c(O.target_branch),h(O.source_branch),r("review")},children:"Review this branch range"})})]},`${O.provider}-${O.number}`))]}),t==="settings"&&P.jsxs("form",{className:"settings","data-testid":"settings-form",onSubmit:F,children:[P.jsx("h1",{children:"Keys & providers"}),P.jsx("p",{className:"muted",children:"Tokens stay on this machine in ~/.loadpath/settings.json. GitHub and Bitbucket power the PR list. Indexed repos are remembered as workspaces. AI is used only for residual uncertainty the graph could not close."}),P.jsx("label",{children:"GitHub token"}),P.jsx("input",{name:"github_token",type:"password",placeholder:"ghp_…"}),P.jsx("label",{children:"Bitbucket token"}),P.jsx("input",{name:"bitbucket_token",type:"password"}),P.jsx("label",{children:"Bitbucket username (app passwords)"}),P.jsx("input",{name:"bitbucket_username",defaultValue:String(I.bitbucket_username||"")}),P.jsx("label",{children:"AI provider"}),P.jsxs("select",{name:"ai_provider",defaultValue:String(((ce=I.ai)==null?void 0:ce.provider)||"none"),children:[P.jsx("option",{value:"none",children:"none (graph only)"}),P.jsx("option",{value:"anthropic",children:"Anthropic"}),P.jsx("option",{value:"openai",children:"OpenAI"}),P.jsx("option",{value:"grok",children:"Grok / xAI"}),P.jsx("option",{value:"deepseek",children:"DeepSeek"}),P.jsx("option",{value:"cursor",children:"Cursor-compatible (OpenAI protocol)"}),P.jsx("option",{value:"ollama",children:"Ollama local"})]}),P.jsx("label",{children:"AI API key"}),P.jsx("input",{name:"ai_api_key",type:"password"}),P.jsx("label",{children:"Model"}),P.jsx("input",{name:"ai_model",placeholder:"optional override"}),P.jsx("label",{children:"Base URL"}),P.jsx("input",{name:"ai_base_url",placeholder:"optional, OpenAI-compatible"}),P.jsx("button",{className:"btn primary",type:"submit",children:"Save"})]})]})]})}P0.createRoot(document.getElementById("root")).render(P.jsx(G.StrictMode,{children:P.jsx(W_,{})})); diff --git a/src/loadpath/static/index.html b/src/loadpath/static/index.html new file mode 100644 index 0000000..ce9e10a --- /dev/null +++ b/src/loadpath/static/index.html @@ -0,0 +1,16 @@ + + + + + + Loadpath + + + + + + + +
+ + diff --git a/src/loadpath/stitch/__init__.py b/src/loadpath/stitch/__init__.py new file mode 100644 index 0000000..839e4d8 --- /dev/null +++ b/src/loadpath/stitch/__init__.py @@ -0,0 +1,3 @@ +from loadpath.stitch.openapi import stitch + +__all__ = ["stitch"] diff --git a/src/loadpath/stitch/openapi.py b/src/loadpath/stitch/openapi.py new file mode 100644 index 0000000..8b8f20c --- /dev/null +++ b/src/loadpath/stitch/openapi.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +from loadpath.config import LoadpathConfig +from loadpath.extractors.react import normalize_url_template +from loadpath.graph.store import GraphStore +from loadpath.types import Edge, EdgeType, Node, NodeType, node_id + +DJANGO_PATH_PARAM = re.compile(r"""<(?:(?:int|str|slug|uuid|path):)?([^>]+)>""") +METHOD_PREFIX = re.compile(r"""^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+""", re.I) + + +def django_route_to_template(route: str) -> str: + route = route.strip() + if not route.startswith("/"): + route = "/" + route + route = DJANGO_PATH_PARAM.sub("{id}", route) + route = re.sub(r"""/+""", "/", route) + return route.rstrip("/") or "/" + + +def parse_public_api(spec: str) -> tuple[str | None, str]: + m = METHOD_PREFIX.match(spec.strip()) + method = m.group(1).upper() if m else None + path = spec[m.end() :].strip() if m else spec.strip() + return method, django_route_to_template(path) + + +def load_openapi(repo_root: Path, config: LoadpathConfig) -> list[dict]: + paths: list[dict] = [] + candidates = list(config.openapi_paths) + if not candidates: + for pattern in ( + "**/schema.yml", + "**/schema.yaml", + "**/openapi.yaml", + "**/openapi.yml", + "**/openapi.json", + "**/schema.json", + "**/swagger.json", + ): + candidates.extend(str(p.relative_to(repo_root)) for p in repo_root.glob(pattern)) + for rel in candidates: + path = repo_root / rel + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + if path.suffix in {".json"}: + data = json.loads(text) + else: + import yaml + + data = yaml.safe_load(text) or {} + for pth, methods in (data.get("paths") or {}).items(): + if not isinstance(methods, dict): + continue + for method, op in methods.items(): + if method.startswith("x-") or not isinstance(op, dict): + continue + paths.append( + { + "path": django_route_to_template(pth), + "method": method.upper(), + "operation_id": op.get("operationId"), + "source": rel, + "generated": True, + } + ) + return paths + + +def _join(prefix: str, route: str) -> str: + prefix = prefix.strip("/") + route = route.strip("/") + if not prefix: + return django_route_to_template(route) + if not route: + return django_route_to_template(prefix) + return django_route_to_template(prefix + "/" + route) + + +def apply_url_includes(store: GraphStore) -> None: + """Compose path('api/', include('billing.urls')) onto child routes.""" + includes: list[tuple[str, str]] = [] + for route in store.nodes([NodeType.ROUTE]): + extra = route.get("extra") or {} + inc = extra.get("include") + if not inc: + continue + prefix = extra.get("route") or route["name"] + includes.append((str(prefix), str(inc))) + if not includes: + return + for route in store.nodes([NodeType.ROUTE]): + extra = dict(route.get("extra") or {}) + if extra.get("include"): + continue + app = extra.get("app") + raw = extra.get("route") or route["name"] + mounted = None + for prefix, inc in includes: + target_app = inc.split(".")[0] + if app and app == target_app: + mounted = _join(prefix, str(raw)) + break + if mounted: + extra["mounted_at"] = mounted + extra["full_path"] = mounted + store.upsert_node( + Node( + id=route["id"], + type=NodeType.ROUTE, + name=mounted, + qualified_name=route["qualified_name"], + file_path=route.get("file_path"), + start_line=route.get("start_line"), + context=route.get("context"), + extra=extra, + ) + ) + store.conn.commit() + + +def stitch(store: GraphStore, config: LoadpathConfig, repo_root: Path) -> list[str]: + residuals: list[str] = [] + apply_url_includes(store) + openapi = load_openapi(repo_root, config) + openapi_by_path: dict[str, list[dict]] = {} + for item in openapi: + openapi_by_path.setdefault(item["path"], []).append(item) + nid = node_id(NodeType.OPENAPI_PATH, f"{item['method']} {item['path']}") + store.upsert_node( + Node( + id=nid, + type=NodeType.OPENAPI_PATH, + name=f"{item['method']} {item['path']}", + qualified_name=f"{item['method']} {item['path']}", + file_path=item.get("source"), + extra=item, + ) + ) + + routes = [n for n in store.nodes([NodeType.ROUTE])] + clients = [n for n in store.nodes([NodeType.API_CLIENT])] + serializers = [n for n in store.nodes([NodeType.SERIALIZER])] + ser_fields = [n for n in store.nodes([NodeType.SERIALIZER_FIELD])] + schemas = [n for n in store.nodes([NodeType.FORM_SCHEMA])] + generated_files = _generated_client_files(repo_root, config) + + # Route → OpenAPI + for route in routes: + extra = route.get("extra") or {} + if extra.get("include"): + continue + raw = extra.get("mounted_at") or extra.get("full_path") or extra.get("route") or route["name"] + tmpl = django_route_to_template(str(raw)) + ops = openapi_by_path.get(tmpl, []) + if not ops: + ops = [item for p, items in openapi_by_path.items() if _paths_match(tmpl, p) for item in items] + for op in ops: + store.upsert_edge( + Edge( + src=route["id"], + dst=node_id(NodeType.OPENAPI_PATH, f"{op['method']} {op['path']}"), + type=EdgeType.PUBLISHES_ROUTE, + confidence=1.0, + extra={"via": "openapi"}, + ) + ) + + # Clients consumed_by matching routes / openapi + for client in clients: + raw = (client.get("extra") or {}).get("raw") or client["name"] + tmpl = normalize_url_template(str(raw)) + matched = False + generated = any( + client.get("file_path") and str(client["file_path"]).replace("\\", "/").endswith(g.replace("\\", "/")) + or (client.get("file_path") and g.replace("\\", "/") in str(client["file_path"]).replace("\\", "/")) + for g in generated_files + ) + if client.get("file_path"): + fp = str(client["file_path"]).replace("\\", "/") + generated = generated or "/generated/" in f"/{fp}/" or "openapi" in Path(fp).name.lower() + + for route in routes: + extra = route.get("extra") or {} + if extra.get("include"): + continue + rraw = extra.get("mounted_at") or extra.get("full_path") or extra.get("route") or route["name"] + rtmpl = django_route_to_template(str(rraw)) + if _paths_match(tmpl, rtmpl): + conf = 0.95 if generated else 0.55 + store.upsert_edge( + Edge( + src=route["id"], + dst=client["id"], + type=EdgeType.CONSUMED_BY_CLIENT, + confidence=conf, + extra={"match": "url_template", "generated_client": generated, "django": rtmpl, "react": tmpl}, + ) + ) + matched = True + if not generated: + residuals.append( + f"Inferred client stitch {tmpl} ↔ {rtmpl} from string URL in {client.get('file_path')} " + "(not a generated OpenAPI client)" + ) + for op in openapi_by_path.get(tmpl, []): + store.upsert_edge( + Edge( + src=node_id(NodeType.OPENAPI_PATH, f"{op['method']} {op['path']}"), + dst=client["id"], + type=EdgeType.CONSUMED_BY_CLIENT, + confidence=1.0 if generated else 0.7, + extra={"via": "openapi", "generated_client": generated}, + ) + ) + matched = True + if not matched and tmpl.startswith("/api/"): + residuals.append(f"React client {tmpl} has no matching Django route ({client.get('file_path')})") + + # Serializer field ↔ Zod field overlap + fields_by_serializer: dict[str, list[dict]] = {} + for f in ser_fields: + parent = f["qualified_name"].rsplit(".", 1)[0] + fields_by_serializer.setdefault(parent, []).append(f) + + for schema in schemas: + zod_fields = set((schema.get("extra") or {}).get("fields") or []) + if not zod_fields: + continue + best: tuple[float, dict, set[str]] | None = None + for ser in serializers: + names = {f["name"] for f in fields_by_serializer.get(ser["qualified_name"], [])} + if not names: + continue + overlap = zod_fields & names + union = zod_fields | names + score = len(overlap) / len(union) if union else 0 + if score >= 0.4 and (best is None or score > best[0]): + best = (score, ser, overlap) + if best: + score, ser, overlap = best + store.upsert_edge( + Edge( + src=ser["id"], + dst=schema["id"], + type=EdgeType.MATCHES_SCHEMA, + confidence=min(0.85, 0.4 + score), + extra={"overlap": sorted(overlap), "score": score, "inferred": True}, + ) + ) + residuals.append( + f"Inferred serializer/Zod overlap {ser['name']} ↔ {schema['name']} " + f"fields={sorted(overlap)} score={score:.2f}" + ) + # field-level edges + ser_fields_map = {f["name"]: f for f in fields_by_serializer.get(ser["qualified_name"], [])} + for fname in overlap: + if fname in ser_fields_map: + store.upsert_edge( + Edge( + src=ser_fields_map[fname]["id"], + dst=schema["id"], + type=EdgeType.MATCHES_SCHEMA, + confidence=0.6, + extra={"field": fname, "inferred": True}, + ) + ) + + # Public API from manifest + for ctx in config.contexts.values(): + for spec in ctx.public_api: + method, path = parse_public_api(spec) + for route in routes: + rraw = (route.get("extra") or {}).get("route") or route["name"] + if _paths_match(django_route_to_template(str(rraw)), path): + if route.get("context") and route["context"] != ctx.name: + store.upsert_edge( + Edge( + src=route["id"], + dst=node_id(NodeType.BOUNDED_CONTEXT, ctx.name), + type=EdgeType.CROSSES_CONTEXT, + extra={"declared_public_api": spec}, + ) + ) + + store.conn.commit() + return residuals + + +def _paths_match(a: str, b: str) -> bool: + a = normalize_url_template(a) + b = django_route_to_template(b) + if a == b: + return True + a_base = re.sub(r"""/\{id\}$""", "", a) + b_base = re.sub(r"""/\{id\}$""", "", b) + if a_base == b_base: + return True + # included Django urls often omit the /api mount until composed + a_tail = a.rsplit("/", 2)[-2:] + b_tail = b.rsplit("/", 2)[-2:] + if a.endswith(b) or b.endswith(a): + return True + return "/".join(a_tail) == "/".join(b_tail) and len(a_tail[-1]) > 2 + + +def _generated_client_files(repo_root: Path, config: LoadpathConfig) -> list[str]: + found: list[str] = [] + for pattern in config.generated_client_globs: + # pathlib doesn't expand {ts,tsx} + if "{" in pattern: + pre, rest = pattern.split("{", 1) + exts = rest.split("}", 1)[0].split(",") + suffix = rest.split("}", 1)[1] if "}" in rest else "" + for ext in exts: + found.extend(str(p.relative_to(repo_root)) for p in repo_root.glob(pre + ext + suffix)) + else: + found.extend(str(p.relative_to(repo_root)) for p in repo_root.glob(pattern)) + return found diff --git a/src/loadpath/types.py b/src/loadpath/types.py new file mode 100644 index 0000000..c605122 --- /dev/null +++ b/src/loadpath/types.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class Layer(StrEnum): + DJANGO = "django" + REACT = "react" + STITCH = "stitch" + ARCH = "arch" + TEST = "test" + + +class NodeType(StrEnum): + # Architecture + BOUNDED_CONTEXT = "arch.context" + # Django + APP = "django.app" + ROUTE = "django.route" + URL_NAME = "django.url_name" + VIEW = "django.view" + VIEWSET_ACTION = "django.viewset_action" + SERIALIZER = "django.serializer" + SERIALIZER_FIELD = "django.serializer_field" + SERVICE = "django.service" + MODEL = "django.model" + FIELD = "django.field" + RELATION = "django.relation" + SIGNAL = "django.signal" + RECEIVER = "django.receiver" + TASK = "django.task" + PERMISSION = "django.permission" + THROTTLE = "django.throttle" + MIGRATION_OP = "django.migration_op" + ADMIN = "django.admin" + MANAGEMENT_COMMAND = "django.management_command" + TEST = "django.test" + # React + REACT_ROUTE = "react.route" + PAGE = "react.page" + FEATURE_MODULE = "react.feature" + COMPONENT = "react.component" + HOOK = "react.hook" + API_CLIENT = "react.api_client" + QUERY_KEY = "react.query_key" + FORM_SCHEMA = "react.form_schema" + CONTEXT_PROVIDER = "react.context" + REACT_TEST = "react.test" + # Stitch + OPENAPI_PATH = "openapi.path" + + +class EdgeType(StrEnum): + CALLS = "calls" + IMPORTS = "imports" + RENDERS = "renders" + BELONGS_TO = "belongs_to" + HAS_FIELD = "has_field" + SERIALIZES = "serializes" + QUERIES_MODEL = "queries_model" + ENQUEUES = "enqueues" + EMITS_SIGNAL = "emits_signal" + RECEIVES = "receives" + PUBLISHES_ROUTE = "publishes_route" + CONSUMED_BY_CLIENT = "consumed_by_client" + CROSSES_CONTEXT = "crosses_context" + CHANGES_PERMISSION = "changes_permission" + DESTRUCTIVE_MIGRATION = "destructive_migration" + TESTED_BY = "tested_by" + USES_QUERY_KEY = "uses_query_key" + MATCHES_SCHEMA = "matches_schema" + USES_SERIALIZER = "uses_serializer" + HAS_PERMISSION = "has_permission" + SERVES = "serves" + RELATES_TO = "relates_to" + + +class EdgeWeight(StrEnum): + CHEAP = "cheap" + EXPENSIVE = "expensive" + CRITICAL = "critical" + + +CHEAP_EDGES = { + EdgeType.CALLS, + EdgeType.IMPORTS, + EdgeType.RENDERS, + EdgeType.BELONGS_TO, + EdgeType.HAS_FIELD, + EdgeType.SERVES, + EdgeType.USES_QUERY_KEY, + EdgeType.RELATES_TO, +} +EXPENSIVE_EDGES = { + EdgeType.SERIALIZES, + EdgeType.QUERIES_MODEL, + EdgeType.ENQUEUES, + EdgeType.EMITS_SIGNAL, + EdgeType.RECEIVES, + EdgeType.USES_SERIALIZER, + EdgeType.HAS_PERMISSION, + EdgeType.TESTED_BY, + EdgeType.MATCHES_SCHEMA, +} +CRITICAL_EDGES = { + EdgeType.PUBLISHES_ROUTE, + EdgeType.CONSUMED_BY_CLIENT, + EdgeType.CROSSES_CONTEXT, + EdgeType.CHANGES_PERMISSION, + EdgeType.DESTRUCTIVE_MIGRATION, +} + +SINK_TYPES = { + NodeType.ROUTE, + NodeType.REACT_ROUTE, + NodeType.PAGE, + NodeType.TASK, + NodeType.MIGRATION_OP, + NodeType.PERMISSION, + NodeType.ADMIN, + NodeType.MANAGEMENT_COMMAND, + NodeType.OPENAPI_PATH, +} + +CONTRACT_TYPES = { + NodeType.SERIALIZER, + NodeType.SERIALIZER_FIELD, + NodeType.OPENAPI_PATH, + NodeType.FORM_SCHEMA, + NodeType.ROUTE, +} + +GENERATED_PATH_MARKERS = ( + "node_modules/", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "poetry.lock", + ".min.js", + "generated/", + "__pycache__/", + ".egg-info/", +) + + +class ChangeKind(StrEnum): + LEAF_UI = "leaf_ui" + INTERNAL_SERVICE = "internal_service" + PUBLIC_CONTRACT = "public_contract" + SCHEMA_MIGRATION = "schema_migration" + AUTH = "auth" + CROSS_CONTEXT = "cross_context" + MIXED = "mixed" + + +class ConfidenceLevel(StrEnum): + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +class RuleSeverity(StrEnum): + BLOCKER = "blocker" + WARNING = "warning" + + +def weight_for(edge_type: EdgeType) -> EdgeWeight: + if edge_type in CRITICAL_EDGES: + return EdgeWeight.CRITICAL + if edge_type in EXPENSIVE_EDGES: + return EdgeWeight.EXPENSIVE + return EdgeWeight.CHEAP + + +def node_id(node_type: NodeType, qualified_name: str) -> str: + return f"{node_type}:{qualified_name}" + + +@dataclass +class Node: + id: str + type: NodeType + name: str + qualified_name: str + file_path: str | None = None + start_line: int | None = None + end_line: int | None = None + context: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + def to_row(self) -> dict[str, Any]: + return { + "id": self.id, + "type": self.type.value, + "name": self.name, + "qualified_name": self.qualified_name, + "file_path": self.file_path, + "start_line": self.start_line, + "end_line": self.end_line, + "context": self.context, + "extra": self.extra, + } + + +@dataclass +class Edge: + src: str + dst: str + type: EdgeType + confidence: float = 1.0 + extra: dict[str, Any] = field(default_factory=dict) + + @property + def weight(self) -> EdgeWeight: + return weight_for(self.type) + + @property + def id(self) -> str: + return f"{self.src}|{self.type}|{self.dst}" + + def to_row(self) -> dict[str, Any]: + return { + "id": self.id, + "src": self.src, + "dst": self.dst, + "type": self.type.value, + "weight": self.weight.value, + "confidence": self.confidence, + "extra": self.extra, + } + + +@dataclass +class ExtractedGraph: + nodes: list[Node] = field(default_factory=list) + edges: list[Edge] = field(default_factory=list) + residuals: list[str] = field(default_factory=list) + + def extend(self, other: ExtractedGraph) -> None: + self.nodes.extend(other.nodes) + self.edges.extend(other.edges) + self.residuals.extend(other.residuals) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a81c1be --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +FIXTURE_ROOT = Path(__file__).resolve().parents[1] / "fixtures" / "demo_monorepo" + + +def copy_fixture(dest: Path) -> Path: + root = dest / "demo" + shutil.copytree(FIXTURE_ROOT, root) + return root + + +def git_init_with_main(repo: Path) -> None: + subprocess.check_call(["git", "init", "-b", "main"], cwd=repo, stdout=subprocess.DEVNULL) + subprocess.check_call(["git", "config", "user.email", "loadpath@test"], cwd=repo) + subprocess.check_call(["git", "config", "user.name", "Loadpath Tests"], cwd=repo) + subprocess.check_call(["git", "add", "-A"], cwd=repo) + subprocess.check_call(["git", "commit", "-m", "baseline"], cwd=repo, stdout=subprocess.DEVNULL) + + +def git_commit_all(repo: Path, message: str) -> None: + subprocess.check_call(["git", "add", "-A"], cwd=repo) + subprocess.check_call(["git", "commit", "-m", message], cwd=repo, stdout=subprocess.DEVNULL) + + +def change_serializer_total(repo: Path) -> None: + path = repo / "backend/billing/serializers.py" + text = path.read_text() + path.write_text( + text.replace( + 'fields = ["id", "customer_id", "total", "status"]', + 'fields = ["id", "customer_id", "total", "status"]\n extra_kwargs = {"total": {"required": True}}', + ) + ) + + +def prepare_review_repo(dest: Path) -> Path: + repo = copy_fixture(dest) + git_init_with_main(repo) + change_serializer_total(repo) + git_commit_all(repo, "tighten Invoice.total contract") + return repo diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_api_flow.py b/tests/e2e/test_api_flow.py new file mode 100644 index 0000000..08b7a73 --- /dev/null +++ b/tests/e2e/test_api_flow.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json + +import httpx +import respx +from fastapi.testclient import TestClient + +from loadpath.server.app import create_app +from tests.conftest import prepare_review_repo + + +def test_api_health_index_review_graph_settings(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + repo = prepare_review_repo(tmp_path) + client = TestClient(create_app()) + + health = client.get("/api/health") + assert health.status_code == 200 + assert health.json()["status"] == "ok" + + indexed = client.post("/api/index", json={"repo_path": str(repo), "incremental": False}) + assert indexed.status_code == 200, indexed.text + summary = indexed.json() + assert summary["counts"]["nodes"] > 20 + assert "billing" in summary["contexts"] + assert summary["indexed_at"] + assert "django.task" in summary["type_counts"] + + status = client.get("/api/index", params={"repo_path": str(repo)}) + assert status.json()["indexed"] is True + assert "nodes" not in status.json() or status.json().get("nodes") is None + + repos = client.get("/api/repos") + assert any(r["path"] == str(repo.resolve()) for r in repos.json()["repos"]) + + arch = client.get("/api/architecture", params={"repo_path": str(repo)}) + assert arch.status_code == 200 + body_arch = arch.json() + assert body_arch["indexed"] is True + assert any(n["name"] == "InvoicePage" for n in body_arch["nodes"]) + assert "celery_tasks_must_be_idempotent_on_model_pk" in body_arch["rules"] + + review = client.post( + "/api/review", + json={"repo_path": str(repo), "base": "HEAD~1", "head": "HEAD", "reindex": False}, + ) + assert review.status_code == 200, review.text + body = review.json() + assert body["confidence"]["level"] in {"high", "medium", "low"} + names = {n["name"] for n in body["nodes"]} + assert "InvoicePage" in names + assert "send_invoice_email" in names + assert "rebuild_ledger" in names + assert "markdown" in body + assert "Celery" in body["headline"] or "celery" in body["headline"].lower() + assert "Dramatiq" in body["headline"] or "dramatiq" in body["headline"].lower() + assert body["index"]["counts"]["nodes"] > 20 + assert body["index"]["reindexed"] is False + + graph = client.get("/api/graph", params={"repo_path": str(repo), "scope": "architecture"}) + assert graph.status_code == 200 + assert graph.json()["scope"] == "architecture" + assert graph.json()["counts"]["edges"] > 0 + + cfg = client.get("/api/config", params={"repo_path": str(repo)}) + assert "billing" in cfg.json()["contexts"] + + saved = client.put( + "/api/settings", + json={"github_token": "ghp_e2e_token_value", "ai_provider": "grok"}, + ) + assert saved.json()["github_token_set"] is True + assert "e2e_token" not in json.dumps(saved.json()) + assert saved.json()["ai"]["provider"] == "grok" + + home = client.get("/") + assert home.status_code == 200 + assert b"Loadpath" in home.content + + +def test_review_without_index_is_conflict(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + repo = prepare_review_repo(tmp_path) + db = repo / ".loadpath" / "graph.sqlite3" + if db.exists(): + db.unlink() + client = TestClient(create_app()) + review = client.post( + "/api/review", + json={"repo_path": str(repo), "base": "HEAD~1", "head": "HEAD", "reindex": False}, + ) + assert review.status_code == 409 + + +@respx.mock +def test_api_lists_github_and_bitbucket_prs(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + client = TestClient(create_app()) + client.put("/api/settings", json={"github_token": "ghp_test", "bitbucket_token": "bb_test"}) + + respx.get("https://api.github.com/repos/acme/demo/pulls").mock( + return_value=httpx.Response( + 200, + json=[ + { + "id": 1, + "number": 42, + "title": "Invoice total field", + "html_url": "https://github.com/acme/demo/pull/42", + "user": {"login": "ada"}, + "head": {"ref": "feat/total"}, + "base": {"ref": "main"}, + "state": "open", + "updated_at": "2026-08-14T00:00:00Z", + "draft": False, + } + ], + ) + ) + gh = client.post("/api/prs", json={"provider": "github", "repo": "acme/demo"}) + assert gh.status_code == 200, gh.text + assert gh.json()["pull_requests"][0]["number"] == 42 + + respx.get("https://api.bitbucket.org/2.0/repositories/acme/demo/pullrequests").mock( + return_value=httpx.Response( + 200, + json={ + "values": [ + { + "id": 7, + "title": "ledger", + "links": {"html": {"href": "https://bitbucket.org/acme/demo/pull-requests/7"}}, + "author": {"display_name": "bob"}, + "source": {"branch": {"name": "feat"}}, + "destination": {"branch": {"name": "main"}}, + "state": "OPEN", + "updated_on": "2026-08-14T00:00:00Z", + } + ] + }, + ) + ) + bb = client.post("/api/prs", json={"provider": "bitbucket", "repo": "acme/demo"}) + assert bb.status_code == 200, bb.text + assert bb.json()["pull_requests"][0]["provider"] == "bitbucket" diff --git a/tests/e2e/test_brokers_and_django.py b/tests/e2e/test_brokers_and_django.py new file mode 100644 index 0000000..529d602 --- /dev/null +++ b/tests/e2e/test_brokers_and_django.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from loadpath.config import load_config +from loadpath.extractors.django_boot import try_boot_models +from loadpath.index import index_repo +from loadpath.review.engine import run_review +from loadpath.types import NodeType +from tests.conftest import FIXTURE_ROOT, copy_fixture, git_commit_all, git_init_with_main, prepare_review_repo + + +def test_serializer_change_includes_celery_and_dramatiq_sinks(tmp_path): + repo = prepare_review_repo(tmp_path) + review = run_review(repo, base="HEAD~1", head="HEAD") + names = {n["name"] for n in review["nodes"]} + types = {n["type"] for n in review["nodes"]} + assert "send_invoice_email" in names + assert "rebuild_ledger" in names + assert "django.task" in types + brokers = { + (n.get("extra") or {}).get("broker") + for n in review["nodes"] + if n["type"] == "django.task" + } + assert "celery" in brokers + assert "dramatiq" in brokers + assert any(e["type"] == "enqueues" for e in review["edges"]) + + +def test_dramatiq_actor_change_is_its_own_load_path(tmp_path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + actors = repo / "backend/billing/actors.py" + actors.write_text(actors.read_text().replace("return str(invoice.total)", "return f'{invoice.total:.2f}'")) + git_commit_all(repo, "touch dramatiq actor") + review = run_review(repo, base="HEAD~1", head="HEAD") + assert any(n["name"] == "rebuild_ledger" for n in review["nodes"]) + assert any((n.get("extra") or {}).get("broker") == "dramatiq" for n in review["nodes"]) + + +def test_non_idempotent_dramatiq_task_warns(tmp_path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + (repo / "backend/billing/actors.py").write_text( + "import dramatiq\n\n@dramatiq.actor\ndef blast(invoice):\n return invoice\n" + ) + git_commit_all(repo, "unsafe dramatiq payload") + review = run_review(repo, base="HEAD~1", head="HEAD") + rules = [f["rule"] for f in review["findings"] if not f.get("waived")] + assert "celery_tasks_must_be_idempotent_on_model_pk" in rules + assert any("Dramatiq" in f["message"] for f in review["findings"]) + + +def test_destructive_migration_is_classified(tmp_path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + mig = repo / "backend/billing/migrations/0002_remove_total.py" + mig.write_text( + "from django.db import migrations\n\n" + "class Migration(migrations.Migration):\n" + " operations = [migrations.RemoveField(model_name='Invoice', name='total')]\n" + ) + git_commit_all(repo, "remove total field") + review = run_review(repo, base="HEAD~1", head="HEAD") + assert "schema_migration" in review["change_kinds"] + assert any(n["type"] == "django.migration_op" for n in review["nodes"]) + assert any(e["type"] == "destructive_migration" for e in review["edges"]) + + +def test_cross_context_view_is_blocker_on_pr(tmp_path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + views = repo / "backend/billing/views.py" + views.write_text("from accounts.models import UserProfile\n" + views.read_text()) + git_commit_all(repo, "leak identity model") + review = run_review(repo, base="HEAD~1", head="HEAD") + assert "cross_context" in review["change_kinds"] + assert review["confidence"]["level"] == "low" + assert any(f["rule"] == "views_cannot_import_other_context_models" for f in review["findings"]) + + +def test_django_boot_overlay_reports_skip_or_models(tmp_path): + cfg = load_config(FIXTURE_ROOT) + cfg.boot_django = True + graph = try_boot_models(FIXTURE_ROOT, cfg) + assert graph.residuals + assert any("django.setup()" in r for r in graph.residuals) + # Either Django isn't installed, settings failed, or models were booted. + if any(n.type is NodeType.MODEL and (n.extra or {}).get("booted") for n in graph.nodes): + assert any(n.name == "Invoice" for n in graph.nodes) + + +def test_index_counts_grow_with_new_django_files(tmp_path): + store = index_repo(FIXTURE_ROOT, db_path=tmp_path / "g.sqlite3", incremental=False) + types = {n["type"] for n in store.nodes()} + assert "django.task" in types + assert "django.management_command" in types + tasks = [n for n in store.nodes() if n["type"] == "django.task"] + brokers = {(n.get("extra") or {}).get("broker") for n in tasks} + assert {"celery", "dramatiq"} <= brokers + names = {n["name"] for n in tasks} + assert "RecalcTotals" in names + assert "NotifyCustomer" in names + assert any((n.get("extra") or {}).get("beat") for n in tasks) + store.close() + + +def test_celery_canvas_and_send_task_are_residuals_on_task_pr(tmp_path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + tasks = repo / "backend/billing/tasks.py" + tasks.write_text(tasks.read_text() + "\n# touch canvas\n") + git_commit_all(repo, "touch celery canvas") + review = run_review(repo, base="HEAD~1", head="HEAD") + blob = " ".join(review["residuals"]) + assert "send_task" in blob or "canvas" in blob.lower() + assert any(n["name"] == "send_invoice_email" for n in review["nodes"]) + + +def test_management_command_change_traces_to_celery(tmp_path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + cmd = repo / "backend/billing/management/commands/resend_invoices.py" + cmd.write_text(cmd.read_text().replace("invoice.id", "invoice.pk")) + git_commit_all(repo, "command uses pk") + review = run_review(repo, base="HEAD~1", head="HEAD") + names = {n["name"] for n in review["nodes"]} + assert "resend_invoices" in names + assert "send_invoice_email" in names + assert any(e["type"] == "enqueues" for e in review["edges"]) + + +def test_ninja_and_fbv_are_indexed(tmp_path): + store = index_repo(FIXTURE_ROOT, db_path=tmp_path / "g.sqlite3", incremental=False) + names = {n["name"] for n in store.nodes()} + assert "invoice_totals" in names + assert any("ledger" in n["name"] for n in store.nodes() if n["type"] == "django.route") + store.close() diff --git a/tests/e2e/test_cli_review.py b/tests/e2e/test_cli_review.py new file mode 100644 index 0000000..14d948c --- /dev/null +++ b/tests/e2e/test_cli_review.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typer.testing import CliRunner + +from loadpath.cli import app +from tests.conftest import prepare_review_repo + +runner = CliRunner() + + +def test_cli_index_review_json_and_html(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + repo = prepare_review_repo(tmp_path) + result = runner.invoke(app, ["index", str(repo)]) + assert result.exit_code == 0, result.output + assert "nodes" in result.output + assert "billing" in result.output + + arch = runner.invoke(app, ["architecture", str(repo)]) + assert arch.exit_code == 0, arch.output + assert "billing" in arch.output + assert "identity" in arch.output + + md = runner.invoke( + app, + ["review", str(repo), "--base", "HEAD~1", "--head", "HEAD", "--no-reindex"], + ) + assert md.exit_code == 0, md.output + assert "Loadpath" in md.output + assert "MEDIUM" in md.output or "LOW" in md.output or "HIGH" in md.output + + json_out = tmp_path / "review.json" + js = runner.invoke( + app, + ["review", str(repo), "--base", "HEAD~1", "--head", "HEAD", "--format", "json", "--out", str(json_out)], + ) + assert js.exit_code == 0, js.output + payload = json_out.read_text() + assert "InvoiceSerializer" in payload + assert "rebuild_ledger" in payload + assert "send_invoice_email" in payload + assert "Dramatiq" in payload or "dramatiq" in payload + assert "Celery" in payload or "celery" in payload + + html_out = tmp_path / "review.html" + html = runner.invoke( + app, + ["review", str(repo), "--base", "HEAD~1", "--head", "HEAD", "--format", "html", "--out", str(html_out)], + ) + assert html.exit_code == 0, html.output + text = html_out.read_text() + assert "vis-network" in text + assert "Loadpath" in text + + +def test_cli_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Loadpath" in result.output + assert "architecture" in result.output + + +def test_cli_serve_help(): + result = runner.invoke(app, ["serve", "--help"]) + assert result.exit_code == 0 + assert "port" in result.output.lower() diff --git a/tests/e2e/test_index_architecture_flow.py b/tests/e2e/test_index_architecture_flow.py new file mode 100644 index 0000000..ef3c3aa --- /dev/null +++ b/tests/e2e/test_index_architecture_flow.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from loadpath.architecture.snapshot import architecture_report +from loadpath.index import default_db_path, index_repo +from tests.conftest import FIXTURE_ROOT, copy_fixture, git_init_with_main, prepare_review_repo + + +def test_index_summary_includes_contexts_and_timestamp(tmp_path): + store = index_repo(FIXTURE_ROOT, db_path=tmp_path / "g.sqlite3", incremental=False) + assert store.get_meta("indexed_at") + assert store.type_counts()["django.task"] >= 2 + store.close() + report = architecture_report(FIXTURE_ROOT, db_path=tmp_path / "g.sqlite3") + assert report["indexed"] is True + assert "billing" in report["contexts"] + assert "identity" in report["contexts"] + assert report["counts"]["nodes"] > 20 + names = {n["name"] for n in report["nodes"]} + assert "InvoiceViewSet" in names + assert "InvoicePage" in names + assert any(n["type"] == "arch.context" for n in report["nodes"]) + + +def test_review_without_index_raises(tmp_path): + from loadpath.review.engine import run_review + + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + try: + run_review(repo, base="HEAD", head="HEAD", reindex=False) + raise AssertionError("expected FileNotFoundError") + except FileNotFoundError as exc: + assert "index" in str(exc).lower() + + +def test_review_walks_existing_index(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + from loadpath.review.engine import run_review + + repo = prepare_review_repo(tmp_path) + store = index_repo(repo, incremental=False) + store.close() + review = run_review(repo, base="HEAD~1", head="HEAD", reindex=False) + assert review["index"]["reindexed"] is False + assert review["index"]["counts"]["nodes"] > 20 + names = {n["name"] for n in review["nodes"]} + assert "InvoicePage" in names + assert default_db_path(repo).is_file() diff --git a/tests/e2e/test_ui_screenshots.py b/tests/e2e/test_ui_screenshots.py new file mode 100644 index 0000000..25994a1 --- /dev/null +++ b/tests/e2e/test_ui_screenshots.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import os +import shutil +import socket +import threading +from collections.abc import Iterator +from pathlib import Path + +import pytest +import uvicorn + +from loadpath.server.app import create_app +from tests.conftest import prepare_review_repo + + +def _free_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +@pytest.fixture +def live_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[str, Path]]: + original_home = Path.home() + monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(original_home / ".cache" / "ms-playwright")) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + repo = prepare_review_repo(tmp_path) + pretty = tmp_path / "acme-billing" + shutil.copytree(repo, pretty) + port = _free_port() + server = uvicorn.Server( + uvicorn.Config(create_app(), host="127.0.0.1", port=port, log_level="warning") + ) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + for _ in range(50): + if server.started: + break + thread.join(0.05) + if not server.started: + pytest.skip("uvicorn failed to start") + try: + yield f"http://127.0.0.1:{port}", pretty + finally: + server.should_exit = True + thread.join(timeout=5) + + +def _playwright_page(): + pytest.importorskip("playwright") + from playwright.sync_api import sync_playwright + + try: + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + except Exception as exc: # noqa: BLE001 + pytest.skip(f"Chromium not available: {exc}") + page = browser.new_page(viewport={"width": 1440, "height": 900}) + return pw, browser, page + + +@pytest.mark.playwright +def test_ui_review_graph_prs_settings(live_app, tmp_path: Path): + base_url, repo = live_app + pw, browser, page = _playwright_page() + try: + page.route( + "**/api/prs", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body='{"pull_requests":[{"provider":"github","id":"1","number":42,"title":"Invoice.total field change","url":"https://github.com/acme/demo/pull/42","author":"ada","source_branch":"feat/total","target_branch":"main","repo":"acme/demo","state":"open","updated_at":"2026-08-14T00:00:00Z","draft":false}]}', + ), + ) + page.goto(base_url, wait_until="networkidle") + try: + page.wait_for_function("document.fonts ? document.fonts.status === 'loaded' : true", timeout=5_000) + except Exception: + pass + assert page.get_by_test_id("rail").is_visible() + page.get_by_test_id("repo-path").fill(str(repo)) + page.get_by_test_id("base-ref").fill("HEAD~1") + page.get_by_test_id("head-ref").fill("HEAD") + + dest = Path(os.environ.get("LOADPATH_SCREENSHOT_DIR", str(tmp_path / "shots"))) + dest.mkdir(parents=True, exist_ok=True) + + with page.expect_response( + lambda r: "/api/index" in r.url and r.request.method == "POST", + timeout=60_000, + ) as pending_index: + page.get_by_test_id("btn-index").click() + if not pending_index.value.ok: + pytest.fail(f"index API {pending_index.value.status}: {pending_index.value.text()}") + page.get_by_test_id("architecture-brief").wait_for(timeout=15_000) + page.get_by_test_id("architecture-brief").locator(".level").wait_for(timeout=15_000) + page.locator(".react-flow__node").first.wait_for(timeout=15_000) + page.wait_for_timeout(800) + page.screenshot(path=str(dest / "architecture.png"), full_page=False) + + with page.expect_response( + lambda r: "/api/review" in r.url and r.request.method == "POST", + timeout=60_000, + ) as pending: + page.get_by_test_id("btn-review").click() + response = pending.value + if not response.ok: + pytest.fail(f"review API {response.status}: {response.text()}") + error = page.locator(".error") + if error.count() and error.inner_text().strip(): + pytest.fail(error.inner_text()) + page.locator(".level").wait_for(timeout=15_000) + brief = page.get_by_test_id("brief").inner_text() + assert "MEDIUM" in brief or "LOW" in brief or "HIGH" in brief + page.locator(".react-flow__node").first.wait_for(timeout=15_000) + page.wait_for_timeout(800) + page.screenshot(path=str(dest / "review.png"), full_page=False) + + page.get_by_test_id("tab-graph").click() + page.get_by_test_id("graph-full").wait_for() + page.locator(".react-flow__node").first.wait_for(timeout=15_000) + page.wait_for_timeout(600) + page.screenshot(path=str(dest / "graph.png"), full_page=False) + + page.get_by_test_id("tab-prs").click() + page.get_by_test_id("pr-repo").fill("acme/demo") + page.get_by_test_id("btn-list-prs").click() + page.get_by_text("#42").wait_for(timeout=10_000) + page.wait_for_timeout(300) + page.screenshot(path=str(dest / "pull-requests.png"), full_page=False) + + page.get_by_test_id("tab-settings").click() + page.get_by_test_id("settings-form").wait_for() + page.locator('select[name="ai_provider"]').select_option("grok") + page.wait_for_timeout(200) + page.screenshot(path=str(dest / "settings.png"), full_page=False) + + for name in ("architecture.png", "review.png", "graph.png", "pull-requests.png", "settings.png"): + assert (dest / name).stat().st_size > 1000 + finally: + browser.close() + pw.stop() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_review_vertical_slice.py b/tests/integration/test_review_vertical_slice.py new file mode 100644 index 0000000..4b763bb --- /dev/null +++ b/tests/integration/test_review_vertical_slice.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from loadpath.review.engine import run_review +from loadpath.review.render import render_html, render_markdown +from loadpath.server.app import create_app +from tests.conftest import copy_fixture, git_commit_all, git_init_with_main + + +def _change_serializer_total(repo: Path) -> None: + path = repo / "backend/billing/serializers.py" + text = path.read_text() + path.write_text( + text.replace( + 'fields = ["id", "customer_id", "total", "status"]', + 'fields = ["id", "customer_id", "total", "status"]\n extra_kwargs = {"total": {"required": True}}', + ) + ) + + +def test_serializer_field_change_traces_to_react_form(tmp_path: Path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + _change_serializer_total(repo) + git_commit_all(repo, "tighten Invoice.total contract") + + review = run_review(repo, base="HEAD~1", head="HEAD") + node_types = {n["type"] for n in review["nodes"]} + names = {n["name"] for n in review["nodes"]} + + assert "django.serializer" in node_types or "django.serializer_field" in node_types + assert "total" in names or any("total" in (n.get("qualified_name") or "") for n in review["nodes"]) + assert any(n["type"] == "django.route" for n in review["nodes"]) + assert any(n["type"] == "react.api_client" for n in review["nodes"]) + assert any(n["name"] in {"useInvoice", "InvoicePage", "InvoiceForm", "invoiceSchema"} for n in review["nodes"]) + assert any(n["type"] == "react.form_schema" for n in review["nodes"]) + assert any(n["type"] == "django.task" and "send_invoice_email" in n["name"] for n in review["nodes"]) + assert any(n["type"] == "react.page" and n["name"] == "InvoicePage" for n in review["nodes"]) + assert not any(n["name"] in {"MePage", "MeView", "MeSerializer"} for n in review["nodes"]) + assert review["suggested_reviewers"] == ["billing-team"] + assert any(e["type"] == "consumed_by_client" for e in review["edges"]) + assert any(e["type"] == "matches_schema" for e in review["edges"]) + + assert review["confidence"]["level"] in {"medium", "low"} + assert "cross_context" not in review["change_kinds"] + assert "public_contract" in review["change_kinds"] + assert any("billing" in (r.lower()) for r in review["suggested_reviewers"]) or review["suggested_reviewers"] == [ + "billing-team" + ] + + md = render_markdown(review) + assert "Loadpath:" in md + assert "Invoice" in md or "total" in md.lower() + html = render_html(review) + assert "vis-network" in html + assert review["nodes"] + + # residual: signal and/or inferred client stitch + blob = " ".join(review["residuals"]) + review["tests_note"] + review["headline"] + assert "InvoiceForm" in blob or "update_ledger" in blob or "inferred" in blob.lower() or "RTL" in blob or "test" in blob.lower() + assert "update_ledger" in " ".join(review["residuals"]) + + +def test_leaf_css_is_low_risk(tmp_path: Path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + css = repo / "frontend/src/features/billing/InvoiceForm.module.css" + css.write_text(".form { padding: 8px; }\n") + git_commit_all(repo, "leaf padding") + review = run_review(repo, base="HEAD~1", head="HEAD") + # css is skipped as non-source; remaining empty / low + assert review["low_risk"] or not review["clusters"] or review["confidence"]["level"] == "high" + + +def test_contract_drift_when_serializer_drops_field(tmp_path: Path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + path = repo / "backend/billing/serializers.py" + path.write_text(path.read_text().replace('"total", ', "")) + git_commit_all(repo, "remove total from serializer") + review = run_review(repo, base="HEAD~1", head="HEAD") + rules = [f["rule"] for f in review["findings"] if not f.get("waived")] + assert "serializers_are_the_only_published_contract" in rules + assert review["confidence"]["level"] == "low" + + +def test_api_review_endpoint(tmp_path: Path): + repo = copy_fixture(tmp_path) + git_init_with_main(repo) + _change_serializer_total(repo) + git_commit_all(repo, "total") + client = TestClient(create_app()) + r = client.post("/api/index", json={"repo_path": str(repo), "incremental": False}) + assert r.status_code == 200, r.text + assert r.json()["counts"]["nodes"] > 10 + r = client.post( + "/api/review", + json={"repo_path": str(repo), "base": "HEAD~1", "head": "HEAD", "reindex": False}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["confidence"]["level"] in {"high", "medium", "low"} + assert "markdown" in body + g = client.get("/api/graph", params={"repo_path": str(repo)}) + assert g.status_code == 200 + assert g.json()["counts"]["nodes"] > 0 + home = client.get("/") + assert home.status_code == 200 + assert b"Loadpath" in home.content diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_ai.py b/tests/unit/test_ai.py new file mode 100644 index 0000000..8d52174 --- /dev/null +++ b/tests/unit/test_ai.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json + +from loadpath.ai.providers import OpenAICompatible, residual_prompt + + +def test_openai_compatible_completion(): + import httpx + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + assert payload["messages"][0]["role"] == "system" + return httpx.Response( + 200, + json={"choices": [{"message": {"content": "Signal update_ledger still formats total."}}]}, + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + ai = OpenAICompatible("sk-test", "grok-3", base_url="https://api.x.ai/v1", client=client) + note = ai.complete("residuals: update_ledger") + assert "update_ledger" in note + + +def test_residual_prompt_includes_graph(): + prompt = residual_prompt( + { + "title": "Invoice.total", + "confidence": {"level": "medium"}, + "change_kinds": ["public_contract"], + "residuals": ["inferred client /api/invoices/{id}"], + "findings": [], + "nodes": [{"type": "django.serializer", "qualified_name": "billing.InvoiceSerializer", "file_path": "s.py"}], + } + ) + assert "Invoice.total" in prompt + assert "inferred client" in prompt diff --git a/tests/unit/test_architecture_rules.py b/tests/unit/test_architecture_rules.py new file mode 100644 index 0000000..eed8251 --- /dev/null +++ b/tests/unit/test_architecture_rules.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +from loadpath.architecture.rules import evaluate +from loadpath.config import load_config +from loadpath.index import index_repo +from loadpath.types import NodeType + +from tests.conftest import FIXTURE_ROOT as FIXTURE + + +def test_queryset_rule_and_task_rule(tmp_path: Path): + store = index_repo(FIXTURE, db_path=tmp_path / "g.sqlite3", incremental=False) + cfg = load_config(FIXTURE) + findings = evaluate(store, cfg) + # healthy fixture: task uses invoice_id, no queryset in serializer + assert not any(f.rule == "no_queryset_in_serializer" and not f.waived for f in findings) + assert not any( + f.rule == "celery_tasks_must_be_idempotent_on_model_pk" and not f.waived for f in findings + ) + store.close() + + +def test_foreign_model_import_is_blocker(tmp_path: Path): + # mutate a copy + import shutil + + root = tmp_path / "repo" + shutil.copytree(FIXTURE, root) + leak = root / "backend/billing/views.py" + text = leak.read_text() + leak.write_text("from accounts.models import UserProfile\n" + text) + store = index_repo(root, db_path=tmp_path / "g.sqlite3", incremental=False) + findings = evaluate(store, load_config(root)) + hits = [f for f in findings if f.rule == "views_cannot_import_other_context_models" and not f.waived] + assert hits, findings + store.close() + + +def test_react_feature_calling_other_context_api(tmp_path: Path): + import shutil + + root = tmp_path / "repo" + shutil.copytree(FIXTURE, root) + api = root / "frontend/src/features/billing/api.ts" + api.write_text(api.read_text() + "\nexport const me = () => fetch('/api/me');\n") + store = index_repo(root, db_path=tmp_path / "g.sqlite3", incremental=False) + findings = evaluate(store, load_config(root)) + hits = [ + f + for f in findings + if f.rule == "react_feature_may_only_call_own_or_shared_api" and not f.waived + ] + assert hits, [f.message for f in findings] + store.close() diff --git a/tests/unit/test_django_extractors.py b/tests/unit/test_django_extractors.py new file mode 100644 index 0000000..204dff8 --- /dev/null +++ b/tests/unit/test_django_extractors.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from loadpath.config import load_config +from loadpath.extractors.django import extract_django_file +from loadpath.types import NodeType + +from tests.conftest import FIXTURE_ROOT as FIXTURE + + +def _cfg(): + return load_config(FIXTURE) + + +def test_extracts_model_fields_and_relations(): + source = (FIXTURE / "backend/billing/models.py").read_text() + g = extract_django_file("backend/billing/models.py", source, _cfg()) + names = {n.qualified_name: n for n in g.nodes} + assert "billing.Invoice" in names + assert "billing.Invoice.total" in names + assert names["billing.Invoice.total"].type is NodeType.FIELD + + +def test_extracts_serializer_fields_and_model_link(): + source = (FIXTURE / "backend/billing/serializers.py").read_text() + g = extract_django_file("backend/billing/serializers.py", source, _cfg()) + fields = [n for n in g.nodes if n.type is NodeType.SERIALIZER_FIELD] + assert {f.name for f in fields} >= {"id", "customer_id", "total", "status"} + assert any(e.type.value == "serializes" for e in g.edges) + + +def test_extracts_view_serializer_permissions_and_enqueue(): + source = (FIXTURE / "backend/billing/views.py").read_text() + g = extract_django_file("backend/billing/views.py", source, _cfg()) + views = [n for n in g.nodes if n.type is NodeType.VIEW] + assert any(v.name == "InvoiceViewSet" for v in views) + assert any(e.type.value == "uses_serializer" for e in g.edges) + assert any(e.type.value == "enqueues" for e in g.edges) + assert any( + e.type.value == "enqueues" and "InvoiceViewSet" in e.src for e in g.edges + ) + perms = [n for n in g.nodes if n.type is NodeType.PERMISSION] + assert any(p.name == "IsAuthenticated" for p in perms) + + +def test_extracts_router_and_path_routes(): + source = (FIXTURE / "backend/billing/urls.py").read_text() + g = extract_django_file("backend/billing/urls.py", source, _cfg()) + routes = [n for n in g.nodes if n.type is NodeType.ROUTE] + assert routes + assert any(e.type.value == "publishes_route" for e in g.edges) + + +def test_extracts_signal_receiver(): + source = (FIXTURE / "backend/billing/signals.py").read_text() + g = extract_django_file("backend/billing/signals.py", source, _cfg()) + rec = [n for n in g.nodes if n.type is NodeType.RECEIVER] + assert rec[0].name == "update_ledger" + assert rec[0].extra.get("sender") in {"Invoice", "billing.Invoice"} + + +def test_extracts_celery_task_pk_idempotency(): + source = (FIXTURE / "backend/billing/tasks.py").read_text() + g = extract_django_file("backend/billing/tasks.py", source, _cfg()) + defined = [ + n + for n in g.nodes + if n.type is NodeType.TASK and n.name == "send_invoice_email" and n.extra.get("looks_idempotent_on_pk") is not None + ] + assert defined + assert defined[0].extra["looks_idempotent_on_pk"] is True + assert defined[0].extra.get("broker") == "celery" + + +def test_extracts_celery_task_class_canvas_and_send_task(): + source = (FIXTURE / "backend/billing/tasks.py").read_text() + g = extract_django_file("backend/billing/tasks.py", source, _cfg()) + recalc = next(n for n in g.nodes if n.name == "RecalcTotals") + assert recalc.type is NodeType.TASK + assert recalc.extra.get("broker") == "celery" + assert recalc.extra.get("task_class") is True + assert any("send_task" in r for r in g.residuals) + assert any("canvas" in r.lower() for r in g.residuals) + assert any( + e.type.value == "enqueues" and "apply_credit" in e.dst for e in g.edges + ) + + +def test_extracts_dramatiq_actor(): + source = (FIXTURE / "backend/billing/actors.py").read_text() + g = extract_django_file("backend/billing/actors.py", source, _cfg()) + actors = [n for n in g.nodes if n.type is NodeType.TASK] + names = {n.name for n in actors} + assert "rebuild_ledger" in names + assert "NotifyCustomer" in names + ledger = next(n for n in actors if n.name == "rebuild_ledger") + assert ledger.extra.get("broker") == "dramatiq" + assert ledger.extra["looks_idempotent_on_pk"] is True + notify = next(n for n in actors if n.name == "NotifyCustomer") + assert notify.extra.get("task_class") is True + + +def test_extracts_celery_beat_schedule(): + source = (FIXTURE / "backend/config/settings.py").read_text() + g = extract_django_file("backend/config/settings.py", source, _cfg()) + assert any(n.extra.get("beat") and n.name == "apply_credit" for n in g.nodes) + assert any("Celery beat" in r for r in g.residuals) + + +def test_management_command_enqueues_celery(): + rel = "backend/billing/management/commands/resend_invoices.py" + source = (FIXTURE / rel).read_text() + g = extract_django_file(rel, source, _cfg()) + assert any(n.type is NodeType.MANAGEMENT_COMMAND for n in g.nodes) + assert any( + e.type.value == "enqueues" and "resend_invoices" in e.src for e in g.edges + ) + + +def test_view_enqueues_celery_and_dramatiq(): + source = (FIXTURE / "backend/billing/views.py").read_text() + g = extract_django_file("backend/billing/views.py", source, _cfg()) + brokers = {e.extra.get("broker") for e in g.edges if e.type.value == "enqueues"} + assert "celery" in brokers + assert "dramatiq" in brokers + assert any(n.type is NodeType.VIEW and n.extra.get("fbv") for n in g.nodes) + view = next(n for n in g.nodes if n.name == "InvoiceViewSet") + assert view.extra.get("get_queryset") is True + assert view.extra.get("filterset") == "InvoiceFilter" + + +def test_extracts_ninja_route(): + source = (FIXTURE / "backend/billing/api.py").read_text() + g = extract_django_file("backend/billing/api.py", source, _cfg()) + assert any(n.type is NodeType.ROUTE and "ledger" in n.name for n in g.nodes) + assert any(n.type is NodeType.VIEW and n.extra.get("ninja") for n in g.nodes) + + +def test_task_definition_file_survives_call_site_placeholder(tmp_path): + from loadpath.index import index_repo + + store = index_repo(FIXTURE, db_path=tmp_path / "g.sqlite3", incremental=False) + ledger = next(n for n in store.nodes() if n["name"] == "rebuild_ledger") + assert "actors.py" in (ledger.get("file_path") or "") + assert not (ledger.get("extra") or {}).get("referenced") + email = next( + n + for n in store.nodes() + if n["name"] == "send_invoice_email" and (n.get("extra") or {}).get("looks_idempotent_on_pk") + ) + assert "tasks.py" in (email.get("file_path") or "") + store.close() + + +def test_extracts_management_command(): + rel = "backend/billing/management/commands/resend_invoices.py" + source = (FIXTURE / rel).read_text() + g = extract_django_file(rel, source, _cfg()) + assert any(n.type is NodeType.MANAGEMENT_COMMAND for n in g.nodes) + + +def test_on_commit_and_apply_async(): + source = (FIXTURE / "backend/billing/tasks.py").read_text() + g = extract_django_file("backend/billing/tasks.py", source, _cfg()) + assert any("on_commit" in r for r in g.residuals) + assert any(e.type.value == "enqueues" and "apply_async" in (e.extra.get("call") or "") for e in g.edges) + + +def test_foreignkey_string_ref(): + source = (FIXTURE / "backend/billing/models.py").read_text() + g = extract_django_file("backend/billing/models.py", source, _cfg()) + assert any("string model ref" in r for r in g.residuals) + account = next(n for n in g.nodes if n.name == "account") + assert account.extra.get("on_delete") == "SET_NULL" + + +def test_extracts_service_and_admin_and_tests(): + cfg = _cfg() + svc = extract_django_file( + "backend/billing/services.py", + (FIXTURE / "backend/billing/services.py").read_text(), + cfg, + ) + assert any(n.type is NodeType.SERVICE and n.name == "recalculate_total" for n in svc.nodes) + admin = extract_django_file( + "backend/billing/admin.py", + (FIXTURE / "backend/billing/admin.py").read_text(), + cfg, + ) + assert any(n.type is NodeType.ADMIN for n in admin.nodes) + tests = extract_django_file( + "backend/billing/tests.py", + (FIXTURE / "backend/billing/tests.py").read_text(), + cfg, + ) + assert any(n.type is NodeType.TEST for n in tests.nodes) + assert any(e.type.value == "tested_by" for e in tests.edges) + + +def test_extracts_migration_ops(): + source = (FIXTURE / "backend/billing/migrations/0001_initial.py").read_text() + g = extract_django_file("backend/billing/migrations/0001_initial.py", source, _cfg()) + assert any(n.type is NodeType.MIGRATION_OP and "CreateModel" in n.name for n in g.nodes) + + +def test_appconfig_ready_is_residual(): + source = (FIXTURE / "backend/billing/apps.py").read_text() + g = extract_django_file("backend/billing/apps.py", source, _cfg()) + assert any("AppConfig.ready" in r for r in g.residuals) + + +def test_get_model_string_ref_is_residual(): + src = 'from django.apps import apps\nm = apps.get_model("billing.Invoice")\n' + g = extract_django_file("backend/billing/dynamic.py", src, _cfg()) + assert any("get_model" in r for r in g.residuals) + + +def test_enqueue_resolves_foreign_app_task(): + src = """ +from rest_framework.viewsets import ModelViewSet +from accounts.tasks import notify_user + +class InvoiceViewSet(ModelViewSet): + def create(self, request): + notify_user.delay(1) +""" + g = extract_django_file("backend/billing/views.py", src, _cfg()) + edges = [e for e in g.edges if e.type.value == "enqueues"] + assert edges + assert any(e.dst == "django.task:accounts.notify_user" for e in edges) + + +def test_discover_settings_uses_django_root(): + from loadpath.extractors.django_boot import _discover_settings_module + + assert _discover_settings_module(FIXTURE, "backend") == "config.settings" + + +def test_cross_app_model_import_on_view(): + src = """ +from rest_framework.views import APIView +from accounts.models import UserProfile + +class LeakView(APIView): + def get(self, request): + return UserProfile.objects.all() +""" + g = extract_django_file("backend/billing/legacy.py", src, _cfg()) + assert any(e.extra.get("foreign_app") == "accounts" for e in g.edges) + + +def test_queryset_in_serializer_flagged(): + src = """ +from rest_framework import serializers +from billing.models import Invoice + +class BadSerializer(serializers.ModelSerializer): + def validate(self, attrs): + Invoice.objects.filter(total=attrs["total"]) + return attrs + class Meta: + model = Invoice + fields = ["total"] +""" + g = extract_django_file("backend/billing/serializers.py", src, _cfg()) + ser = next(n for n in g.nodes if n.type is NodeType.SERIALIZER) + assert ser.extra.get("queryset_in_serializer") is True diff --git a/tests/unit/test_index_and_stitch.py b/tests/unit/test_index_and_stitch.py new file mode 100644 index 0000000..73cc2ec --- /dev/null +++ b/tests/unit/test_index_and_stitch.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from pathlib import Path + +from loadpath.config import load_config +from loadpath.index import index_repo +from loadpath.types import NodeType + +from tests.conftest import FIXTURE_ROOT as FIXTURE + + +def test_index_stitches_django_route_to_react_client(tmp_path: Path): + db = tmp_path / "graph.sqlite3" + store = index_repo(FIXTURE, db_path=db, incremental=False) + edges = store.edges() + consumed = [e for e in edges if e["type"] == "consumed_by_client"] + assert consumed, "expected URL stitch between Django routes and React fetch" + inferred = [e for e in consumed if e["confidence"] < 0.9] + assert inferred, "string-matched fetch must be marked inferred (lower confidence)" + schema_edges = [e for e in edges if e["type"] == "matches_schema"] + assert schema_edges, "serializer fields should overlap invoiceSchema" + store.close() + + +def test_index_is_incremental(tmp_path: Path): + db = tmp_path / "graph.sqlite3" + store = index_repo(FIXTURE, db_path=db, incremental=False) + first = store.counts() + store.close() + store = index_repo(FIXTURE, db_path=db, incremental=True) + second = store.counts() + store.close() + assert first["nodes"] == second["nodes"] + + +def test_incremental_index_drops_deleted_files(tmp_path: Path): + import shutil + + root = tmp_path / "repo" + shutil.copytree(FIXTURE, root) + db = tmp_path / "g.sqlite3" + store = index_repo(root, db_path=db, incremental=False) + assert any(n["name"] == "InvoicePage" for n in store.nodes()) + store.close() + (root / "frontend/src/features/billing/InvoicePage.tsx").unlink() + store = index_repo(root, db_path=db, incremental=True) + assert not any(n["name"] == "InvoicePage" for n in store.nodes()) + store.close() + + +def test_incremental_reindex_keeps_enqueue_edges(tmp_path: Path): + import shutil + + root = tmp_path / "repo" + shutil.copytree(FIXTURE, root) + db = tmp_path / "g.sqlite3" + store = index_repo(root, db_path=db, incremental=False) + before = [e for e in store.edges() if e["type"] == "enqueues"] + assert before + store.close() + tasks = root / "backend/billing/tasks.py" + tasks.write_text(tasks.read_text() + "\n# touch\n") + store = index_repo(root, db_path=db, incremental=True) + after = [e for e in store.edges() if e["type"] == "enqueues"] + assert len(after) >= len(before) + store.close() + + +def test_contexts_assigned(tmp_path: Path): + store = index_repo(FIXTURE, db_path=tmp_path / "g.sqlite3", incremental=False) + invoice = next(n for n in store.nodes([NodeType.MODEL]) if n["name"] == "Invoice") + assert invoice["context"] == "billing" + store.close() diff --git a/tests/unit/test_providers_and_api.py b/tests/unit/test_providers_and_api.py new file mode 100644 index 0000000..1236c80 --- /dev/null +++ b/tests/unit/test_providers_and_api.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from loadpath.providers.scm import BitbucketProvider, GitHubProvider +from loadpath.server.app import create_app +from loadpath.settings import AppSettings + + +def test_github_lists_pull_requests(): + import httpx + + client = httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response( + 200, + json=[ + { + "id": 9, + "number": 12, + "title": "Invoice total", + "html_url": "https://github.com/acme/demo/pull/12", + "user": {"login": "ada"}, + "head": {"ref": "feat/total"}, + "base": {"ref": "main"}, + "state": "open", + "updated_at": "2026-08-14T00:00:00Z", + "draft": False, + } + ], + ))) + gh = GitHubProvider("tok", client=client) + prs = gh.list_pull_requests("acme/demo") + assert prs[0].number == 12 + assert prs[0].source_branch == "feat/total" + + +def test_bitbucket_lists_pull_requests(): + import httpx + + payload = { + "values": [ + { + "id": 3, + "title": "auth", + "links": {"html": {"href": "https://bitbucket.org/acme/demo/pull-requests/3"}}, + "author": {"display_name": "bob"}, + "source": {"branch": {"name": "feat"}}, + "destination": {"branch": {"name": "main"}}, + "state": "OPEN", + "updated_on": "2026-08-14T00:00:00Z", + } + ] + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + bb = BitbucketProvider("tok", username="bob", client=client) + prs = bb.list_pull_requests("acme/demo") + assert prs[0].number == 3 + assert prs[0].provider == "bitbucket" + + +def test_health_and_settings(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + app = create_app() + c = TestClient(app) + assert c.get("/api/health").json()["status"] == "ok" + r = c.put("/api/settings", json={"github_token": "ghp_secret_token", "ai_provider": "anthropic"}) + body = r.json() + assert body["github_token_set"] is True + assert "secret" not in body["github_token"] + assert body["ai"]["provider"] == "anthropic" + kept = c.put("/api/settings", json={"github_token": "", "bitbucket_token": "", "ai_api_key": ""}) + assert kept.json()["github_token_set"] is True + + +def test_github_rejects_unsafe_repo_slug(): + import pytest + + gh = GitHubProvider("tok") + with pytest.raises(ValueError): + gh.list_pull_requests("../etc/passwd") diff --git a/tests/unit/test_react_extractors.py b/tests/unit/test_react_extractors.py new file mode 100644 index 0000000..66a8586 --- /dev/null +++ b/tests/unit/test_react_extractors.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from loadpath.config import load_config +from loadpath.extractors.react import extract_react_file, normalize_url_template +from loadpath.types import NodeType + +from tests.conftest import FIXTURE_ROOT as FIXTURE + + +def _cfg(): + return load_config(FIXTURE) + + +def test_normalize_url_templates(): + assert normalize_url_template("/api/invoices/${id}") == "/api/invoices/{id}" + assert normalize_url_template("`/api/invoices/${id}`".strip("`")) == "/api/invoices/{id}" + + +def test_extracts_react_routes_and_pages(): + source = (FIXTURE / "frontend/src/App.tsx").read_text() + g = extract_react_file("frontend/src/App.tsx", source, _cfg()) + routes = [n for n in g.nodes if n.type is NodeType.REACT_ROUTE] + assert any(n.name == "/invoices/:id" for n in routes) + assert any(n.type is NodeType.PAGE and n.name == "InvoicePage" for n in g.nodes) + + +def test_extracts_hook_query_key_and_fetch_url(): + hook = extract_react_file( + "frontend/src/features/billing/useInvoice.ts", + (FIXTURE / "frontend/src/features/billing/useInvoice.ts").read_text(), + _cfg(), + ) + assert any(n.type is NodeType.HOOK and n.name == "useInvoice" for n in hook.nodes) + assert any(n.type is NodeType.QUERY_KEY for n in hook.nodes) + api = extract_react_file( + "frontend/src/features/billing/api.ts", + (FIXTURE / "frontend/src/features/billing/api.ts").read_text(), + _cfg(), + ) + clients = [n for n in api.nodes if n.type is NodeType.API_CLIENT] + assert any(c.name == "/api/invoices/{id}" for c in clients) + + +def test_extracts_zod_schema_fields(): + g = extract_react_file( + "frontend/src/features/billing/invoiceSchema.ts", + (FIXTURE / "frontend/src/features/billing/invoiceSchema.ts").read_text(), + _cfg(), + ) + schema = next(n for n in g.nodes if n.type is NodeType.FORM_SCHEMA) + assert schema.name == "invoiceSchema" + assert set(schema.extra["fields"]) >= {"customer_id", "total", "status"} + + +def test_extracts_page_renders_form(): + g = extract_react_file( + "frontend/src/features/billing/InvoicePage.tsx", + (FIXTURE / "frontend/src/features/billing/InvoicePage.tsx").read_text(), + _cfg(), + ) + assert any(n.type is NodeType.PAGE and n.name == "InvoicePage" for n in g.nodes) + assert any(e.type.value == "renders" for e in g.edges) + + +def test_feature_context_from_path(): + cfg = _cfg() + g = extract_react_file( + "frontend/src/features/auth/MePage.tsx", + (FIXTURE / "frontend/src/features/auth/MePage.tsx").read_text(), + cfg, + ) + pages = [n for n in g.nodes if n.type in {NodeType.PAGE, NodeType.HOOK}] + assert any(n.context == "identity" for n in pages) diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..b325dab --- /dev/null +++ b/ui/index.html @@ -0,0 +1,15 @@ + + + + + + Loadpath + + + + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..3f9fa0b --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,4266 @@ +{ + "name": "loadpath-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loadpath-ui", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vitest": "^2.1.8" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.3", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz", + "integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.80", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.80", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz", + "integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.406", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz", + "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..ef0c542 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "loadpath-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@xyflow/react": "^12.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vitest": "^2.1.8" + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..f3e781f --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,478 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { api } from "./api"; +import { ImpactGraph } from "./ImpactGraph"; +import type { ArchitectureReport, IndexedRepo, PullRequest, Review } from "./types"; + +type Tab = "review" | "architecture" | "graph" | "prs" | "settings"; +type GraphMode = "review" | "architecture"; + +export function App() { + const [tab, setTab] = useState("review"); + const [repo, setRepo] = useState(localStorage.getItem("loadpath.repo") || ""); + const [base, setBase] = useState("HEAD~1"); + const [head, setHead] = useState("HEAD"); + const [review, setReview] = useState(null); + const [architecture, setArchitecture] = useState(null); + const [repos, setRepos] = useState([]); + const [graphMode, setGraphMode] = useState("review"); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(""); + const [settings, setSettings] = useState>({}); + const [prs, setPrs] = useState([]); + const [scmRepo, setScmRepo] = useState(""); + const [provider, setProvider] = useState("github"); + const [aiNote, setAiNote] = useState(""); + const repoRef = useRef(repo); + repoRef.current = repo; + + useEffect(() => { + api.settings().then(setSettings).catch(() => undefined); + api.repos().then((r) => setRepos(r.repos)).catch(() => undefined); + }, []); + + useEffect(() => { + if (tab !== "architecture" || !repo) return; + const requested = repo; + let cancelled = false; + api + .architecture(requested) + .then((report) => { + if (!cancelled && repoRef.current === requested) setArchitecture(report); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [tab, repo]); + + const persistRepo = (path: string) => { + setRepo(path); + localStorage.setItem("loadpath.repo", path); + }; + + const loadArchitecture = async (path = repo) => { + if (!path) return null; + const report = await api.architecture(path); + if (repoRef.current === path) setArchitecture(report); + return report; + }; + + const runReview = async () => { + setError(""); + setBusy("Tracing load path…"); + persistRepo(repo); + try { + const r = await api.review(repo, base, head, true); + setReview(r); + setGraphMode("review"); + setTab("review"); + await api.repos().then((x) => setRepos(x.repos)).catch(() => undefined); + await loadArchitecture(repo); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + }; + + const runIndex = async (incremental = true) => { + setError(""); + setBusy(incremental ? "Indexing…" : "Full reindex…"); + persistRepo(repo); + try { + await api.index(repo, incremental); + const report = await loadArchitecture(repo); + await api.repos().then((x) => setRepos(x.repos)).catch(() => undefined); + if (report?.indexed) { + setGraphMode("architecture"); + setTab("architecture"); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + }; + + const loadPrs = async () => { + setError(""); + setBusy("Fetching pull requests…"); + try { + const r = await api.prs(provider, scmRepo); + setPrs(r.pull_requests); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + }; + + const saveSettings = async (evt: React.FormEvent) => { + evt.preventDefault(); + const fd = new FormData(evt.currentTarget); + const body = { + github_token: String(fd.get("github_token") || ""), + bitbucket_token: String(fd.get("bitbucket_token") || ""), + bitbucket_username: String(fd.get("bitbucket_username") || ""), + ai_provider: String(fd.get("ai_provider") || "none"), + ai_api_key: String(fd.get("ai_api_key") || ""), + ai_model: String(fd.get("ai_model") || ""), + ai_base_url: String(fd.get("ai_base_url") || ""), + workspaces: repos.length + ? repos.map((r) => ({ path: r.path, name: r.name })) + : repo + ? [{ path: repo, name: repo.split(/[\\/]/).pop() }] + : [], + }; + setSettings(await api.saveSettings(body)); + }; + + const askAi = async () => { + if (!review) return; + setBusy("Residual analysis…"); + try { + const r = await api.residual(review); + setAiNote(r.note); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + }; + + const graphNodes = useMemo(() => { + if (graphMode === "architecture") return architecture?.nodes ?? []; + return review?.nodes ?? []; + }, [graphMode, architecture, review]); + const graphEdges = useMemo(() => { + if (graphMode === "architecture") return architecture?.edges ?? []; + return review?.edges ?? []; + }, [graphMode, architecture, review]); + + const indexLine = review?.index + ? `${review.index.counts.nodes} nodes / ${review.index.counts.edges} edges · ${review.index.incremental ? "incremental" : "full"}` + : architecture?.indexed + ? `${architecture.counts.nodes} nodes / ${architecture.counts.edges} edges indexed` + : "Not indexed"; + + return ( +
+ +
+
+ {repos.length > 0 ? ( + + ) : null} + setRepo(e.target.value)} + /> + setBase(e.target.value)} placeholder="base" /> + setHead(e.target.value)} placeholder="head" /> + + +
+ {error ?
{error}
: null} + + {tab === "review" && ( +
+ +
+ {review ? : null} +
+
+ )} + + {tab === "architecture" && ( +
+ +
+ {architecture?.indexed ? ( + + ) : null} +
+
+ )} + + {tab === "graph" && ( +
+
+ + +
+ {graphNodes.length ? ( +
+ +
+ ) : ( +

Index the repo or run a review first.

+ )} +
+ )} + + {tab === "prs" && ( +
+
+ + setScmRepo(e.target.value)} + /> + +
+ {prs.map((p) => ( +
+

+ #{p.number} {p.title} +

+
+ {p.author} · {p.source_branch} → {p.target_branch} · {p.provider} +
+ + Open on {p.provider} + +
+ +
+
+ ))} +
+ )} + + {tab === "settings" && ( +
+

Keys & providers

+

+ Tokens stay on this machine in ~/.loadpath/settings.json. GitHub and Bitbucket power the PR list. Indexed + repos are remembered as workspaces. AI is used only for residual uncertainty the graph could not close. +

+ + + + + + + + + + + + + + + +
+ )} +
+
+ ); +} diff --git a/ui/src/ImpactGraph.tsx b/ui/src/ImpactGraph.tsx new file mode 100644 index 0000000..56bc856 --- /dev/null +++ b/ui/src/ImpactGraph.tsx @@ -0,0 +1,55 @@ +import { Background, Controls, MiniMap, ReactFlow, ReactFlowProvider, type Edge, type Node } from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { layoutNodes, type GraphEdge, type GraphNode } from "./types"; + +const WEIGHT_COLOR: Record = { + cheap: "#4a5568", + expensive: "#f4a261", + critical: "#e85d04", +}; + +function LoadNode({ data }: { data: { name: string; type: string } }) { + return ( +
+
{data.type.split(".").pop()}
+
{data.name}
+
+ ); +} + +const nodeTypes = { load: LoadNode }; + +export function ImpactGraph({ nodes, edges }: { nodes: GraphNode[]; edges: GraphEdge[] }) { + const pos = layoutNodes(nodes); + const rfNodes: Node[] = nodes.map((n) => ({ + id: n.id, + type: "load", + position: pos.get(n.id) ?? { x: 0, y: 0 }, + data: { name: n.name, type: n.type, file: n.file_path }, + })); + const rfEdges: Edge[] = edges + .filter((e) => nodes.some((n) => n.id === e.src) && nodes.some((n) => n.id === e.dst)) + .map((e) => ({ + id: e.id, + source: e.src, + target: e.dst, + animated: e.weight === "critical", + style: { + stroke: WEIGHT_COLOR[e.weight] || "#4a5568", + strokeWidth: e.weight === "critical" ? 2.4 : 1.2, + strokeDasharray: e.confidence < 0.8 ? "6 4" : undefined, + }, + label: e.type.replaceAll("_", " "), + labelStyle: { fill: "#8b9bb0", fontSize: 9 }, + })); + + return ( + + + + + + + + ); +} diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 0000000..87af4d6 --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,49 @@ +import type { ArchitectureReport, IndexedRepo, PullRequest, Review } from "./types"; + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.json() as Promise; +} + +export const api = { + health: () => req<{ status: string; version: string }>("/api/health"), + settings: () => req>("/api/settings"), + saveSettings: (body: Record) => + req>("/api/settings", { method: "PUT", body: JSON.stringify(body) }), + repos: () => req<{ repos: IndexedRepo[] }>("/api/repos"), + index: (repo_path: string, incremental = true) => + req("/api/index", { + method: "POST", + body: JSON.stringify({ repo_path, incremental }), + }), + indexStatus: (repo_path: string) => + req(`/api/index?repo_path=${encodeURIComponent(repo_path)}`), + architecture: (repo_path: string) => + req(`/api/architecture?repo_path=${encodeURIComponent(repo_path)}`), + review: (repo_path: string, base: string, head?: string, reindex = true) => + req("/api/review", { + method: "POST", + body: JSON.stringify({ repo_path, base, head: head || null, reindex, incremental: true }), + }), + graph: (repo_path: string, scope: "full" | "architecture" = "full") => + req<{ nodes: unknown[]; edges: unknown[]; counts: { nodes: number; edges: number } }>( + `/api/graph?repo_path=${encodeURIComponent(repo_path)}&scope=${scope}`, + ), + prs: (provider: string, repo: string, state = "open") => + req<{ pull_requests: PullRequest[] }>("/api/prs", { + method: "POST", + body: JSON.stringify({ provider, repo, state }), + }), + residual: (review: Review) => + req<{ note: string }>("/api/ai/residual", { + method: "POST", + body: JSON.stringify({ review }), + }), +}; diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..9891129 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..923ac4d --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,166 @@ +:root { + --bg: #070b10; + --bg-2: #0d141c; + --surface: #121a24; + --line: #1e2c3c; + --ink: #e7eef6; + --muted: #8b9bb0; + --high: #2a9d8f; + --medium: #e9c46a; + --low: #e76f51; + --critical: #e85d04; + --accent: #4cc9f0; +} +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { + background: var(--bg); + color: var(--ink); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} +button, input, select, textarea { + font-family: inherit; + color: inherit; +} +.app { + display: grid; + grid-template-columns: 220px 1fr; + height: 100%; +} +.rail { + border-right: 1px solid var(--line); + background: linear-gradient(180deg, #0b1219, #070b10); + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.brand { + font-family: "IBM Plex Mono", monospace; + letter-spacing: 0.18em; + font-size: 12px; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 18px; +} +.rail button, .tab { + background: transparent; + border: 0; + text-align: left; + padding: 8px 10px; + border-radius: 6px; + color: var(--muted); + cursor: pointer; +} +.rail button.active, .tab.active { + background: #15202c; + color: var(--ink); +} +.main { display: flex; flex-direction: column; min-width: 0; } +.topbar { + display: flex; + gap: 8px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--bg-2); +} +.topbar input, .topbar select { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + min-width: 160px; +} +.topbar input.path { flex: 1; } +.topbar button, .btn { + background: #173044; + border: 1px solid #24506c; + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; +} +.btn.primary { background: #134e4a; border-color: #2a9d8f; } +.content { flex: 1; min-height: 0; display: grid; grid-template-columns: 420px 1fr; } +.brief { + overflow: auto; + border-right: 1px solid var(--line); + padding: 18px; + background: radial-gradient(circle at 0 0, rgba(76, 201, 240, 0.05), transparent 40%), var(--bg); +} +.graph-wrap { position: relative; min-height: 0; } +.graph-wrap .react-flow { + background-color: #070b10; + background-image: + linear-gradient(rgba(42, 80, 120, 0.09) 1px, transparent 1px), + linear-gradient(90deg, rgba(42, 80, 120, 0.09) 1px, transparent 1px); + background-size: 24px 24px; +} +h1 { font-size: 18px; margin: 0 0 6px; } +.level { font-family: "IBM Plex Mono", monospace; font-weight: 600; } +.level.high { color: var(--high); } +.level.medium { color: var(--medium); } +.level.low { color: var(--low); } +.kicker { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; margin: 16px 0 6px; } +.chip { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); + margin: 0 6px 6px 0; + color: var(--muted); +} +.chip.blocker { color: var(--low); border-color: var(--low); } +.file { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--accent); } +.muted { color: var(--muted); font-size: 13px; line-height: 1.45; } +.error { color: var(--low); padding: 8px 16px; } +.pr-list { padding: 16px; overflow: auto; } +.pr { + border: 1px solid var(--line); + background: var(--surface); + padding: 12px 14px; + border-radius: 8px; + margin-bottom: 10px; +} +.pr h3 { margin: 0 0 4px; font-size: 14px; } +.settings { padding: 24px; max-width: 640px; display: grid; gap: 10px; } +.settings label { font-size: 12px; color: var(--muted); } +.settings input, .settings select { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + width: 100%; +} +.headline { white-space: pre-wrap; font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--muted); } +.graph-modes { + display: flex; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + background: var(--bg-2); +} +.graph-modes button { + background: transparent; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 10px; + color: var(--muted); + cursor: pointer; +} +.graph-modes button.active { + background: #15202c; + color: var(--ink); + border-color: #2a9d8f; +} +.lp-node { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #2a3d52; + background: #101822; + min-width: 150px; + box-shadow: 0 0 0 1px rgba(76, 201, 240, 0.08); +} +.lp-node .t { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; } +.lp-node .n { font-size: 13px; font-weight: 600; } diff --git a/ui/src/types.test.ts b/ui/src/types.test.ts new file mode 100644 index 0000000..12ff0c7 --- /dev/null +++ b/ui/src/types.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { layerFor, layoutNodes, type GraphNode } from "./types"; + +describe("load-path layout", () => { + it("places Django left of the React stitch", () => { + const nodes: GraphNode[] = [ + { id: "r", type: "django.route", name: "/api/invoices", qualified_name: "r" }, + { id: "c", type: "react.api_client", name: "/api/invoices/{id}", qualified_name: "c" }, + { id: "p", type: "react.page", name: "InvoicePage", qualified_name: "p" }, + { id: "f", type: "react.form_schema", name: "invoiceSchema", qualified_name: "f" }, + ]; + const pos = layoutNodes(nodes); + expect(pos.get("r")!.x).toBeLessThan(pos.get("c")!.x); + expect(pos.get("c")!.x).toBeLessThan(pos.get("p")!.x); + expect(pos.get("p")!.x).toBeLessThan(pos.get("f")!.x); + expect(layerFor("django.serializer_field")).toBeLessThan(layerFor("react.form_schema")); + }); +}); diff --git a/ui/src/types.ts b/ui/src/types.ts new file mode 100644 index 0000000..c74b1ca --- /dev/null +++ b/ui/src/types.ts @@ -0,0 +1,168 @@ +export type GraphNode = { + id: string; + type: string; + name: string; + qualified_name: string; + file_path?: string | null; + start_line?: number | null; + context?: string | null; + extra?: Record; +}; + +export type GraphEdge = { + id: string; + src: string; + dst: string; + type: string; + weight: string; + confidence: number; + extra?: Record; +}; + +export type Finding = { + rule: string; + severity: string; + message: string; + node_id?: string | null; + file_path?: string | null; + waived: boolean; +}; + +export type Review = { + id: string; + title: string; + headline: string; + change_kinds: string[]; + confidence: { + level: "high" | "medium" | "low"; + reasons: string[]; + sinks: number; + covered_sinks: number; + untested_sinks: { id: string; name: string; type: string }[]; + }; + labels: string[]; + low_risk: boolean; + clusters: { id: string; title: string; files: string[]; contexts: string[] }[]; + read_order: { path: string; why: string; status: string }[]; + skip: string[]; + findings: Finding[]; + residuals: string[]; + suggested_reviewers: string[]; + sinks: { id: string; type: string; name: string }[]; + tests_note: string; + architecture_note: string; + nodes: GraphNode[]; + edges: GraphEdge[]; + markdown?: string; + index?: { + db: string; + counts: { nodes: number; edges: number }; + type_counts?: Record; + indexed_at?: string | null; + reindexed: boolean; + incremental: boolean; + }; +}; + +export type ArchitectureReport = { + ok: boolean; + indexed: boolean; + repo_root: string; + db?: string; + indexed_at?: string | null; + incremental?: boolean; + counts: { nodes: number; edges: number }; + type_counts: Record; + file_count?: number; + contexts: Record< + string, + { name: string; django_apps: string[]; react: string[]; public_api: string[]; owners: string[] } + >; + rules: string[]; + findings: Finding[]; + residuals: string[]; + has_config?: boolean; + nodes: GraphNode[]; + edges: GraphEdge[]; +}; + +export type IndexedRepo = { + path: string; + name: string; + exists: boolean; + indexed: boolean; + counts: { nodes: number; edges: number }; + indexed_at?: string | null; + contexts?: string[]; + has_config?: boolean; +}; + +export type PullRequest = { + provider: string; + id: string; + number: number; + title: string; + url: string; + author: string; + source_branch: string; + target_branch: string; + repo: string; + state: string; + updated_at: string; + draft: boolean; +}; + +export const LAYER_ORDER: Record = { + "arch.context": 0, + "django.app": 0, + "django.route": 1, + "django.url_name": 1, + "django.view": 2, + "django.viewset_action": 2, + "django.permission": 2, + "django.serializer": 3, + "django.serializer_field": 4, + "django.service": 4, + "django.model": 5, + "django.field": 6, + "django.relation": 6, + "django.task": 7, + "django.receiver": 7, + "django.signal": 7, + "django.test": 7, + "django.migration_op": 7, + "django.admin": 7, + "openapi.path": 8, + "react.api_client": 9, + "react.query_key": 10, + "react.hook": 10, + "react.feature": 10, + "react.route": 11, + "react.page": 11, + "react.component": 12, + "react.form_schema": 13, + "react.test": 13, + "react.context": 12, +}; + +export function layerFor(type: string): number { + return LAYER_ORDER[type] ?? 8; +} + +export function layoutNodes(nodes: GraphNode[]): Map { + const columns = new Map(); + for (const n of nodes) { + const layer = layerFor(n.type); + const list = columns.get(layer) ?? []; + list.push(n); + columns.set(layer, list); + } + const pos = new Map(); + for (const [layer, list] of columns) { + list.sort((a, b) => a.name.localeCompare(b.name)); + list.forEach((n, i) => { + pos.set(n.id, { x: layer * 240, y: i * 92 }); + }); + } + return pos; +} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..8a35f7c --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..3867620 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + base: "./", + build: { + outDir: "../src/loadpath/static", + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:7345", + }, + }, + test: { + environment: "jsdom", + }, +});