From f6f049cd419aedf8668f40bc0a6787737f85a2cb Mon Sep 17 00:00:00 2001 From: cvetty Date: Fri, 24 Jul 2026 18:16:09 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20`whygraph=20serve`=20?= =?UTF-8?q?=E2=80=94=20the=20Explorer=20playground=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read-only, loopback-only web UI served from the existing Docker image as its own long-lived container, launched with `whygraph serve`. The web API is a thin second adapter over the exact service functions the MCP tools call, so the panel's rationale/evidence/history can never drift from the MCP's. Backend (src/whygraph/serve/, FastAPI): - graphdata: bounded ego-graph with server-computed layered coordinates + a lazy dir→file→symbol containment tree (no client-side force layout — the fix for the old viewer being slow/glitchy). - routes: /api search, tree, graph/ego, node detail, evidence, history, commit/pr/issue. Rationale is split — GET is cache-only (never calls an LLM); POST runs whygraph_rationale_brief verbatim (the explicit Generate action). - Phase 2: lifting (weighted, directional edge roll-up to dir/file super-nodes) + coverage heatmap + /api/graph/overview (the LOD landing view). - app: SPA fallback; degrades gracefully to an API-only "UI not built" page. CodeGraph: generalize _calls_relations into relations(kind, incoming); add imports_/container/children/files + Phase-2 file_edges/definition_ranges. Empirically confirmed the `contains` edge direction against real data. Playground (src/playground/): Vite + React + TS, Tailwind (shadcn-style), @xyflow/react ego graph + elkjs overview, TanStack Query, cmdk palette, and a single canonical openNode() used by search, graph clicks, and relationship rows. Delivery: multi-stage Dockerfile (arch-independent playground-build stage) + the `whygraph` shim's `serve` branch (loopback -p, WHYGRAPH_PORT, --detach/ --stop/--logs) + a hatch build hook that packs the bundle into the wheel via `artifacts`. Makefile gains `playground`, `playground-dev`, and a `dev` HMR loop. Deps: fastapi>=0.110, uvicorn>=0.27. 558 tests pass; ruff clean. --- .gitignore | 7 + Makefile | 25 +- docker/whygraph/Dockerfile | 22 + hatch_build.py | 58 + pyproject.toml | 10 + src/playground/index.html | 12 + src/playground/package-lock.json | 3489 +++++++++++++++++ src/playground/package.json | 33 + src/playground/postcss.config.js | 6 + src/playground/src/App.tsx | 55 + src/playground/src/api.ts | 204 + .../src/components/CommandPalette.tsx | 77 + src/playground/src/components/DetailPanel.tsx | 95 + .../src/components/EvidenceList.tsx | 68 + src/playground/src/components/EvidenceTab.tsx | 19 + src/playground/src/components/GraphCanvas.tsx | 118 + src/playground/src/components/HistoryTab.tsx | 23 + src/playground/src/components/Overview.tsx | 162 + .../src/components/OverviewNode.tsx | 59 + .../src/components/RationaleTab.tsx | 103 + .../src/components/RelationshipsTab.tsx | 55 + src/playground/src/components/SymbolNode.tsx | 39 + src/playground/src/components/Tree.tsx | 166 + src/playground/src/index.css | 41 + src/playground/src/lib/ui.tsx | 76 + src/playground/src/main.tsx | 19 + src/playground/src/store.ts | 27 + src/playground/tailwind.config.js | 24 + src/playground/tsconfig.json | 21 + src/playground/vite.config.ts | 22 + src/whygraph/cli/__init__.py | 2 + src/whygraph/cli/commands/install.py | 58 +- src/whygraph/cli/commands/serve.py | 39 + src/whygraph/serve/__init__.py | 22 + src/whygraph/serve/app.py | 100 + src/whygraph/serve/coverage.py | 55 + src/whygraph/serve/graphdata.py | 239 ++ src/whygraph/serve/lifting.py | 131 + src/whygraph/serve/routes.py | 237 ++ src/whygraph/services/codegraph/graph.py | 166 +- tests/test_serve_api.py | 324 ++ tests/test_serve_phase2.py | 145 + tests/test_services_codegraph.py | 80 + uv.lock | 20 + 44 files changed, 6742 insertions(+), 11 deletions(-) create mode 100644 hatch_build.py create mode 100644 src/playground/index.html create mode 100644 src/playground/package-lock.json create mode 100644 src/playground/package.json create mode 100644 src/playground/postcss.config.js create mode 100644 src/playground/src/App.tsx create mode 100644 src/playground/src/api.ts create mode 100644 src/playground/src/components/CommandPalette.tsx create mode 100644 src/playground/src/components/DetailPanel.tsx create mode 100644 src/playground/src/components/EvidenceList.tsx create mode 100644 src/playground/src/components/EvidenceTab.tsx create mode 100644 src/playground/src/components/GraphCanvas.tsx create mode 100644 src/playground/src/components/HistoryTab.tsx create mode 100644 src/playground/src/components/Overview.tsx create mode 100644 src/playground/src/components/OverviewNode.tsx create mode 100644 src/playground/src/components/RationaleTab.tsx create mode 100644 src/playground/src/components/RelationshipsTab.tsx create mode 100644 src/playground/src/components/SymbolNode.tsx create mode 100644 src/playground/src/components/Tree.tsx create mode 100644 src/playground/src/index.css create mode 100644 src/playground/src/lib/ui.tsx create mode 100644 src/playground/src/main.tsx create mode 100644 src/playground/src/store.ts create mode 100644 src/playground/tailwind.config.js create mode 100644 src/playground/tsconfig.json create mode 100644 src/playground/vite.config.ts create mode 100644 src/whygraph/cli/commands/serve.py create mode 100644 src/whygraph/serve/__init__.py create mode 100644 src/whygraph/serve/app.py create mode 100644 src/whygraph/serve/coverage.py create mode 100644 src/whygraph/serve/graphdata.py create mode 100644 src/whygraph/serve/lifting.py create mode 100644 src/whygraph/serve/routes.py create mode 100644 tests/test_serve_api.py create mode 100644 tests/test_serve_phase2.py diff --git a/.gitignore b/.gitignore index 7dc0efe..83f20da 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,13 @@ whygraph.toml # Local scratch space for in-progress plan markdown (not version-controlled). plans/ +# Explorer playground: node deps, Vite output, and the built bundle packed into +# the wheel at build time (Docker COPY --from / hatch build hook) — never committed. +src/playground/node_modules/ +src/playground/dist/ +src/playground/*.tsbuildinfo +src/whygraph/serve/static/ + # MkDocs build output (the site is built and deployed by CI, never committed). site/ diff --git a/Makefile b/Makefile index 6f07862..bdee548 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ IMAGE ?= whygraph:dev # Name of the long-running container started by `make image-debug`. DEBUG_NAME ?= whygraph-debug -.PHONY: help sync test scan docs docs-build db db-down inspect image image-test image-inspect image-debug image-debug-down +.PHONY: help sync test scan node-check playground-deps playground playground-dev dev serve docs docs-build db db-down inspect image image-test image-inspect image-debug image-debug-down help: ## List available targets @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN{FS=":.*?## "}{printf " %-10s %s\n", $$1, $$2}' @@ -27,6 +27,29 @@ test: ## Run the test suite scan: ## Re-scan this repo so WhyGraph is tested against itself uv run whygraph scan +node-check: # (internal) assert Node >= 18 for the playground toolchain + @node -e 'process.exit(+process.versions.node.split(".")[0]>=18?0:1)' 2>/dev/null || { echo "error: the playground needs Node >= 18 (have $$(node -v 2>/dev/null || echo none)) - try 'nvm use 22'"; exit 1; } + +playground-deps: node-check # (internal) install node_modules only if missing + @[ -d src/playground/node_modules ] || npm --prefix src/playground ci + +playground: node-check ## Production build of the Explorer SPA into src/whygraph/serve/static + npm --prefix src/playground ci + npm --prefix src/playground run build + +playground-dev: playground-deps ## Vite dev server with HMR (:5173, proxies /api to :8765) - pair with a backend or use 'make dev' + npm --prefix src/playground run dev + +dev: playground-deps ## Dev loop: backend (:8765) + Vite HMR (:5173) together; open :5173; Ctrl-C stops both + @echo "backend -> http://localhost:8765 playground (HMR) -> http://localhost:5173 (open :5173)" + @uv run whygraph serve & \ + api_pid=$$!; \ + trap 'kill $$api_pid 2>/dev/null' EXIT INT TERM; \ + npm --prefix src/playground run dev + +serve: playground ## Production preview: build the SPA then serve it from whygraph serve (:8765) + uv run whygraph serve + docs: ## Serve the docs site locally with live reload (social cards skipped — no Cairo needed) uv run mkdocs serve diff --git a/docker/whygraph/Dockerfile b/docker/whygraph/Dockerfile index 16b7e1b..c965217 100644 --- a/docker/whygraph/Dockerfile +++ b/docker/whygraph/Dockerfile @@ -17,6 +17,22 @@ # syntax=docker/dockerfile:1.7 +# --- Playground build stage ----------------------------------------------- +# Builds the Explorer SPA (src/playground/) into a static bundle that the final +# stage copies into the wheel. `--platform=$BUILDPLATFORM` pins this to the +# native builder: JS output is arch-independent, so we avoid QEMU emulating +# the npm build once per target arch (the multi-arch gotcha). +FROM --platform=$BUILDPLATFORM node:22-slim AS playground-build +WORKDIR /playground +COPY src/playground/package.json src/playground/package-lock.json ./ +RUN npm ci +COPY src/playground/ ./ +# Override the outDir to a stage-local `dist` (the checked-in vite.config.ts +# writes straight into the package for local `make playground`); the final stage +# COPYs it to the packaged location. +RUN npm run build -- --outDir dist --emptyOutDir + +# --- Runtime stage -------------------------------------------------------- FROM python:3.12-slim # Pinned via build arg so the version can be advanced without editing the @@ -63,7 +79,13 @@ ENV WHYGRAPH_VERSION=${WHYGRAPH_VERSION} # scripts land on PATH. Copy the build inputs hatchling needs and install. WORKDIR /opt/whygraph COPY pyproject.toml ./ +COPY hatch_build.py ./ COPY src ./src +# The pre-built SPA bundle must exist under src/ BEFORE `pip install .` so +# hatchling packs it into the wheel. Copied from the playground stage, which +# means the hatch build hook (§9.3) finds static/ already populated and no-ops +# — the image build never runs npm. +COPY --from=playground-build /playground/dist ./src/whygraph/serve/static RUN pip install --no-cache-dir . # Image-only shortcut so `docker run IMAGE install | sh` works without an diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..908dd39 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,58 @@ +"""Hatchling build hook that ships the Explorer SPA bundle in the wheel. + +The React playground (``src/playground/``) builds to ``src/whygraph/serve/static/``, which +is gitignored and produced only at build time. This hook makes ``uv tool install`` +/ ``pip install`` from a source tree build the bundle automatically, so the wheel +always carries a working SPA. + +Behaviour, in order: + +1. If ``src/whygraph/serve/static/index.html`` already exists, do nothing — the + Docker image ``COPY --from``s a pre-built bundle before ``pip install``, so the + hook must be a **no-op** there (the image build never runs npm). +2. Else, if ``src/playground/`` and ``npm`` are both present, run ``npm ci`` + + ``npm run build`` to populate ``static/``. +3. Else (no bundle, no npm), warn and continue: the server still runs and its + ``/`` route reports the UI is not built (see :mod:`whygraph.serve.app`). +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class PlaygroundBuildHook(BuildHookInterface): + """Build the playground bundle into the package tree before packaging.""" + + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict) -> None: + root = Path(self.root) + static = root / "src" / "whygraph" / "serve" / "static" + playground = root / "src" / "playground" + + if (static / "index.html").is_file(): + # Already built (Docker COPY --from, or a prior `make playground`). + return + + if not (playground / "package.json").is_file(): + self.app.display_warning( + "src/playground/ not found — packaging without the Explorer SPA bundle; " + "`whygraph serve` will report the UI is not built at /." + ) + return + + if shutil.which("npm") is None: + self.app.display_warning( + "npm not found — packaging without the Explorer SPA bundle; " + "install Node and rebuild, or run `make playground`." + ) + return + + self.app.display_info("building Explorer playground (npm ci && npm run build)…") + subprocess.run(["npm", "ci"], cwd=playground, check=True) + subprocess.run(["npm", "run", "build"], cwd=playground, check=True) diff --git a/pyproject.toml b/pyproject.toml index 530a2b8..d3ee298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "Rationale layer over CodeGraph — explains why code exists, not requires-python = ">=3.11" dependencies = [ "mcp[cli]>=1.2", + "fastapi>=0.110", + "uvicorn>=0.27", "click>=8.1", "rich>=13", "scikit-learn>=1.3", @@ -27,6 +29,14 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/whygraph"] +# The built SPA bundle is gitignored; `artifacts` force-includes it so the wheel +# carries it (populated by the build hook or the Docker COPY --from). +artifacts = ["src/whygraph/serve/static/**"] + +[tool.hatch.build.hooks.custom] +# Runs hatch_build.py:PlaygroundBuildHook — builds src/playground/ into serve/static/ +# at wheel-build time (no-op if the bundle is already present). +path = "hatch_build.py" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/playground/index.html b/src/playground/index.html new file mode 100644 index 0000000..c9c66b9 --- /dev/null +++ b/src/playground/index.html @@ -0,0 +1,12 @@ + + + + + + WhyGraph Explorer + + +
+ + + diff --git a/src/playground/package-lock.json b/src/playground/package-lock.json new file mode 100644 index 0000000..3d75cce --- /dev/null +++ b/src/playground/package-lock.json @@ -0,0 +1,3489 @@ +{ + "name": "whygraph-explorer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "whygraph-explorer", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "@xyflow/react": "^12.3.5", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "elkjs": "^0.9.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^5.0.1" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", + "integrity": "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.1.tgz", + "integrity": "sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.17.tgz", + "integrity": "sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-effect-event": "0.0.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.5.tgz", + "integrity": "sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.14.tgz", + "integrity": "sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-callback-ref": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.3.tgz", + "integrity": "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.15.tgz", + "integrity": "sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.9.tgz", + "integrity": "sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.8.tgz", + "integrity": "sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.1.tgz", + "integrity": "sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.3.tgz", + "integrity": "sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.5.tgz", + "integrity": "sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.4", + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.4.tgz", + "integrity": "sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.3.tgz", + "integrity": "sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "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/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "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/react/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 + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "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.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, + "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/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/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/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "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/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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "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": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "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/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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/tinyglobby/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/tinyglobby/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/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "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/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": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/src/playground/package.json b/src/playground/package.json new file mode 100644 index 0000000..0d1d80a --- /dev/null +++ b/src/playground/package.json @@ -0,0 +1,33 @@ +{ + "name": "whygraph-explorer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "@xyflow/react": "^12.3.5", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "elkjs": "^0.9.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^5.0.1" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/src/playground/postcss.config.js b/src/playground/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/src/playground/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/playground/src/App.tsx b/src/playground/src/App.tsx new file mode 100644 index 0000000..5e056ca --- /dev/null +++ b/src/playground/src/App.tsx @@ -0,0 +1,55 @@ +import { useEffect } from "react"; +import { Tree } from "./components/Tree"; +import { GraphCanvas } from "./components/GraphCanvas"; +import { Overview } from "./components/Overview"; +import { DetailPanel } from "./components/DetailPanel"; +import { CommandPalette } from "./components/CommandPalette"; +import { useExplorer } from "./store"; + +export default function App() { + const setPaletteOpen = useExplorer((s) => s.setPaletteOpen); + const selectedQn = useExplorer((s) => s.selectedQn); + + // Global ⌘K / Ctrl-K opens the command palette. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [setPaletteOpen]); + + return ( +
+
+
+ WhyGraph Explorer +
+ +
+ +
+ +
+ {selectedQn ? : } +
+ +
+ + +
+ ); +} diff --git a/src/playground/src/api.ts b/src/playground/src/api.ts new file mode 100644 index 0000000..d37ed61 --- /dev/null +++ b/src/playground/src/api.ts @@ -0,0 +1,204 @@ +// Typed client for the WhyGraph Explorer API. Shapes mirror `serve/routes.py` +// exactly — the same payloads the MCP tools serve, over HTTP. + +export interface Symbol { + id: string; + qualified_name: string; + name: string; + kind: string; + file_path: string; + start_line: number; + end_line: number; + signature: string | null; +} + +export interface SearchResult extends Symbol { + analyzed: boolean; +} + +export interface TreeEntry { + id: string; + label: string; + kind: string; // "directory" | file/class/method/… + has_children: boolean; + node_id?: string; + qualified_name?: string; + path?: string; + dir?: string; +} + +export interface RelationSymbol extends Symbol { + edge_kind?: string; + edge_line?: number | null; +} + +export interface NodeRelations { + callers: RelationSymbol[]; + callees: RelationSymbol[]; + imports: RelationSymbol[]; + container: Symbol | null; + children: Symbol[]; +} + +export interface NodeDetail { + symbol: Symbol; + analyzed: boolean; + relations: NodeRelations; +} + +export interface EgoNode { + id: string; + position: { x: number; y: number }; + data: Symbol & { is_focus: boolean }; +} + +export interface EgoEdge { + id: string; + source: string; + target: string; + kind: string; +} + +export interface EgoGraph { + focus: string; + nodes: EgoNode[]; + edges: EgoEdge[]; +} + +export interface OverviewNodeDto { + id: string; + kind: "directory" | "file"; + label: string; + path: string; + coverage: { analyzed: number; total: number; fraction: number }; + internal_edges: number; +} + +export interface OverviewEdgeDto { + id: string; + source: string; + target: string; + kind: string; + weight: number; +} + +export interface OverviewGraph { + expanded: string[]; + nodes: OverviewNodeDto[]; + edges: OverviewEdgeDto[]; +} + +export interface RationaleCard { + status: "cached" | "not_generated" | "no_evidence"; + target?: { path: string; line_start: number; line_end: number }; + purpose?: string; + why?: string; + constraints?: string[]; + tradeoffs?: string[]; + risks?: string[]; + model?: string; + provider?: string; + cached_at?: string; + evidence_count?: { commits: number; prs: number; issues: number }; +} + +export interface CommitDict { + sha: string; + subject: string; + body: string | null; + llm_description: string | null; + author_name: string; + author_email: string; + authored_at: string; + committed_at: string; +} + +export interface PullRequestDict { + number: number; + title: string; + html_url: string | null; + state: string; +} + +export interface IssueDict { + number: number; + title: string; + html_url: string | null; + state: string; +} + +export interface EvidenceItem { + commit: CommitDict; + pull_requests: PullRequestDict[]; + issues: IssueDict[]; + source: string; +} + +export interface EvidenceResponse { + target: unknown; + evidence: EvidenceItem[]; +} + +export interface HistoryResponse { + path: string; + include_renames: boolean; + evidence: EvidenceItem[]; +} + +class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +async function get(path: string): Promise { + const res = await fetch(`/api${path}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError(res.status, body.detail ?? body.error ?? res.statusText); + } + return res.json() as Promise; +} + +async function post(path: string): Promise { + const res = await fetch(`/api${path}`, { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError(res.status, body.detail ?? body.error ?? res.statusText); + } + return res.json() as Promise; +} + +const q = (qn: string) => encodeURIComponent(qn); + +export const api = { + search: (query: string, limit = 20) => + get<{ query: string; results: SearchResult[] }>( + `/search?q=${encodeURIComponent(query)}&limit=${limit}`, + ), + tree: (opts: { dir?: string; node?: string } = {}) => { + const params = new URLSearchParams(); + if (opts.dir) params.set("dir", opts.dir); + if (opts.node) params.set("node", opts.node); + const qs = params.toString(); + return get<{ entries: TreeEntry[] }>(`/tree${qs ? `?${qs}` : ""}`); + }, + overview: (expanded = "") => + get(`/graph/overview?expanded=${encodeURIComponent(expanded)}`), + ego: (qualified_name: string) => + get(`/graph/ego?qualified_name=${q(qualified_name)}`), + node: (qualified_name: string) => get(`/node/${q(qualified_name)}`), + rationaleRead: (qualified_name: string) => + get(`/node/${q(qualified_name)}/rationale`), + rationaleGenerate: (qualified_name: string) => + post(`/node/${q(qualified_name)}/rationale`), + evidence: (qualified_name: string, limit = 20) => + get(`/node/${q(qualified_name)}/evidence?limit=${limit}`), + history: (path: string, limit = 20) => + get(`/history?path=${encodeURIComponent(path)}&limit=${limit}`), +}; + +export { ApiError }; diff --git a/src/playground/src/components/CommandPalette.tsx b/src/playground/src/components/CommandPalette.tsx new file mode 100644 index 0000000..d183a96 --- /dev/null +++ b/src/playground/src/components/CommandPalette.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { Command } from "cmdk"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { useExplorer } from "../store"; +import { KindBadge, CoverageDot } from "../lib/ui"; + +// Cmd-K search. Results come from the server (`api.search`), so cmdk's built-in +// fuzzy filtering is disabled. Selecting a row fires the canonical `openNode()` +// with the file path, so the tree can auto-reveal and the graph recentres. + +function useDebounced(value: T, ms: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), ms); + return () => clearTimeout(t); + }, [value, ms]); + return debounced; +} + +export function CommandPalette() { + const open = useExplorer((s) => s.paletteOpen); + const setOpen = useExplorer((s) => s.setPaletteOpen); + const openNode = useExplorer((s) => s.openNode); + const [query, setQuery] = useState(""); + const debounced = useDebounced(query, 150); + + const { data, isFetching } = useQuery({ + queryKey: ["search", debounced], + queryFn: () => api.search(debounced), + enabled: open && debounced.trim().length > 0, + }); + + const results = data?.results ?? []; + + return ( + +
setOpen(false)} /> + + + {debounced.trim().length === 0 && ( +
Type to search symbols…
+ )} + {debounced.trim().length > 0 && !isFetching && results.length === 0 && ( + + No symbols match “{debounced}”. + + )} + {results.map((r) => ( + openNode(r.qualified_name, r.file_path)} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm text-fg data-[selected=true]:bg-accent/20" + > + + + {r.name} + {r.file_path} + + ))} +
+ + ); +} diff --git a/src/playground/src/components/DetailPanel.tsx b/src/playground/src/components/DetailPanel.tsx new file mode 100644 index 0000000..1e600be --- /dev/null +++ b/src/playground/src/components/DetailPanel.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { clsx } from "clsx"; +import { api } from "../api"; +import { useExplorer } from "../store"; +import { KindBadge, Spinner, EmptyState } from "../lib/ui"; +import { RelationshipsTab } from "./RelationshipsTab"; +import { RationaleTab } from "./RationaleTab"; +import { EvidenceTab } from "./EvidenceTab"; +import { HistoryTab } from "./HistoryTab"; + +type TabKey = "relationships" | "rationale" | "evidence" | "history"; +const TABS: { key: TabKey; label: string }[] = [ + { key: "relationships", label: "Relationships" }, + { key: "rationale", label: "Rationale" }, + { key: "evidence", label: "Evidence" }, + { key: "history", label: "History" }, +]; + +export function DetailPanel() { + const selectedQn = useExplorer((s) => s.selectedQn); + const [tab, setTab] = useState("relationships"); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["node", selectedQn], + queryFn: () => api.node(selectedQn!), + enabled: !!selectedQn, + }); + + if (!selectedQn) + return ( +
+ Select a symbol to see its details. +
+ ); + + return ( +
+ {/* Sticky identity header */} +
+ {isLoading && } + {isError &&
{(error as Error).message}
} + {data && ( + <> +
+ + + {data.symbol.name} + +
+
+ {data.symbol.qualified_name} +
+
+ {data.symbol.file_path}:{data.symbol.start_line} +
+ + )} +
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Tab body */} +
+ {!data ? ( + + ) : tab === "relationships" ? ( + + ) : tab === "rationale" ? ( + + ) : tab === "evidence" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/playground/src/components/EvidenceList.tsx b/src/playground/src/components/EvidenceList.tsx new file mode 100644 index 0000000..02403bd --- /dev/null +++ b/src/playground/src/components/EvidenceList.tsx @@ -0,0 +1,68 @@ +import type { EvidenceItem } from "../api"; +import { EmptyState } from "../lib/ui"; + +// Shared renderer for the evidence bundle used by the Evidence and History tabs. +// All fields are untrusted repo content (commit messages, PR/issue titles); React +// escapes text by default and we never use dangerouslySetInnerHTML (§6). + +function ExternalLink({ href, children }: { href: string | null; children: string }) { + if (!href) return {children}; + return ( + + {children} + + ); +} + +function EvidenceCard({ item }: { item: EvidenceItem }) { + const c = item.commit; + return ( +
+
+ + {c.sha.slice(0, 8)} + + + {item.source} + +
+
{c.subject}
+ {c.llm_description && ( +
{c.llm_description}
+ )} +
+ {c.author_name} · {c.authored_at} +
+ {(item.pull_requests.length > 0 || item.issues.length > 0) && ( +
+ {item.pull_requests.map((pr) => ( + + {`#${pr.number} ${pr.title}`} + + ))} + {item.issues.map((issue) => ( + + {`issue #${issue.number} ${issue.title}`} + + ))} +
+ )} +
+ ); +} + +export function EvidenceList({ items, empty }: { items: EvidenceItem[]; empty: string }) { + if (items.length === 0) return {empty}; + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/src/playground/src/components/EvidenceTab.tsx b/src/playground/src/components/EvidenceTab.tsx new file mode 100644 index 0000000..60d6bf2 --- /dev/null +++ b/src/playground/src/components/EvidenceTab.tsx @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { Spinner, EmptyState } from "../lib/ui"; +import { EvidenceList } from "./EvidenceList"; + +// The Evidence tab — always available and LLM-free (line-blame + linked PRs/issues). +export function EvidenceTab({ qualifiedName }: { qualifiedName: string }) { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["evidence", qualifiedName], + queryFn: () => api.evidence(qualifiedName), + }); + + if (isLoading) return
; + if (isError) + return Failed to load evidence: {(error as Error).message}; + return ( + + ); +} diff --git a/src/playground/src/components/GraphCanvas.tsx b/src/playground/src/components/GraphCanvas.tsx new file mode 100644 index 0000000..44e7274 --- /dev/null +++ b/src/playground/src/components/GraphCanvas.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { + ReactFlow, + Background, + Controls, + MarkerType, + type Node, + type Edge, + type NodeMouseHandler, +} from "@xyflow/react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { useExplorer } from "../store"; +import { SymbolNode, type SymbolNodeData } from "./SymbolNode"; +import { Spinner } from "../lib/ui"; + +// The center canvas: the one-hop ego graph of the selected symbol. Coordinates +// come from the server (§0 rendering strategy) — the client only pans/zooms and +// never runs a force simulation, the direct fix for the old viewer's jank. + +const nodeTypes = { symbol: SymbolNode }; + +const EDGE_COLOR: Record = { + calls: "#818cf8", + imports: "#fb7185", + contains: "#64748b", +}; + +export function GraphCanvas() { + const selectedQn = useExplorer((s) => s.selectedQn); + const openNode = useExplorer((s) => s.openNode); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["ego", selectedQn], + queryFn: () => api.ego(selectedQn!), + enabled: !!selectedQn, + }); + + const nodes = useMemo( + () => + (data?.nodes ?? []).map((n) => ({ + id: n.id, + type: "symbol", + position: n.position, + data: n.data as unknown as SymbolNodeData, + })), + [data], + ); + + const edges = useMemo( + () => + (data?.edges ?? []).map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + label: e.kind, + animated: e.kind === "calls", + style: { stroke: EDGE_COLOR[e.kind] ?? "#64748b" }, + labelStyle: { fill: "#8b93a7", fontSize: 10 }, + labelBgStyle: { fill: "#12151c" }, + markerEnd: { type: MarkerType.ArrowClosed, color: EDGE_COLOR[e.kind] ?? "#64748b" }, + })), + [data], + ); + + const onNodeClick: NodeMouseHandler = (_, node) => { + const d = node.data as unknown as SymbolNodeData; + if (!d.is_focus) openNode(d.qualified_name, d.file_path); + }; + + if (!selectedQn) + return ( +
+
+
WhyGraph Explorer
+
+ Pick a symbol from the tree, or press{" "} + + ⌘K + {" "} + to search. +
+
+
+ ); + + if (isLoading) + return ( +
+ +
+ ); + + if (isError) + return ( +
+ {(error as Error).message} +
+ ); + + return ( + + + + + ); +} diff --git a/src/playground/src/components/HistoryTab.tsx b/src/playground/src/components/HistoryTab.tsx new file mode 100644 index 0000000..9b2d08b --- /dev/null +++ b/src/playground/src/components/HistoryTab.tsx @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { Spinner, EmptyState } from "../lib/ui"; +import { EvidenceList } from "./EvidenceList"; + +// The History tab — area history for the symbol's file (path-keyed), reaching +// commits that line-blame cannot (deleted/renamed/rewritten code). +export function HistoryTab({ path }: { path: string }) { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["history", path], + queryFn: () => api.history(path), + }); + + if (isLoading) return
; + if (isError) + return Failed to load history: {(error as Error).message}; + return ( + + ); +} diff --git a/src/playground/src/components/Overview.tsx b/src/playground/src/components/Overview.tsx new file mode 100644 index 0000000..890a9a1 --- /dev/null +++ b/src/playground/src/components/Overview.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState } from "react"; +import { + ReactFlow, + Background, + Controls, + MarkerType, + type Node, + type Edge, + type NodeMouseHandler, +} from "@xyflow/react"; +import { useQuery } from "@tanstack/react-query"; +import ELK from "elkjs/lib/elk.bundled.js"; +import { api } from "../api"; +import { OverviewNode, type OverviewNodeData } from "./OverviewNode"; +import { Spinner } from "../lib/ui"; + +// The Phase-2 LOD overview and landing view: directory super-nodes with weighted, +// directional lifted edges and coverage coloring. Clicking a directory expands it +// (server re-lifts for the new expansion state). Layout runs client-side with elk +// (the node count is bounded by the expansion state, so it stays fast). + +const nodeTypes = { overview: OverviewNode }; +const elk = new ELK(); +const NODE_W = 190; +const NODE_H = 72; + +interface OverviewApiNode { + id: string; + kind: "directory" | "file"; + label: string; + path: string; + coverage: { analyzed: number; total: number; fraction: number }; + internal_edges: number; +} +interface OverviewApiEdge { + id: string; + source: string; + target: string; + kind: string; + weight: number; +} + +async function layout( + apiNodes: OverviewApiNode[], + apiEdges: OverviewApiEdge[], +): Promise> { + const graph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.spacing.nodeNode": "40", + "elk.layered.spacing.nodeNodeBetweenLayers": "70", + }, + children: apiNodes.map((n) => ({ id: n.id, width: NODE_W, height: NODE_H })), + edges: apiEdges.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] })), + }; + const res = await elk.layout(graph); + const pos: Record = {}; + for (const c of res.children ?? []) pos[c.id] = { x: c.x ?? 0, y: c.y ?? 0 }; + return pos; +} + +export function Overview() { + const [expanded, setExpanded] = useState>(new Set()); + const [positions, setPositions] = useState>({}); + + const expandedParam = useMemo(() => [...expanded].sort().join(","), [expanded]); + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["overview", expandedParam], + queryFn: () => api.overview(expandedParam), + }); + + useEffect(() => { + if (!data) return; + let alive = true; + layout(data.nodes, data.edges).then((pos) => { + if (alive) setPositions(pos); + }); + return () => { + alive = false; + }; + }, [data]); + + const nodes = useMemo( + () => + (data?.nodes ?? []).map((n) => ({ + id: n.id, + type: "overview", + position: positions[n.id] ?? { x: 0, y: 0 }, + data: n as unknown as OverviewNodeData, + })), + [data, positions], + ); + + const edges = useMemo( + () => + (data?.edges ?? []).map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + label: e.weight > 1 ? String(e.weight) : undefined, + style: { + stroke: e.kind === "imports" ? "#fb7185" : "#818cf8", + strokeWidth: Math.min(1 + e.weight / 3, 4), + }, + labelStyle: { fill: "#8b93a7", fontSize: 10 }, + labelBgStyle: { fill: "#12151c" }, + markerEnd: { type: MarkerType.ArrowClosed }, + })), + [data], + ); + + const onNodeClick: NodeMouseHandler = (_, node) => { + const d = node.data as unknown as OverviewNodeData & { path: string }; + if (d.kind !== "directory") return; + setExpanded((prev) => { + const next = new Set(prev); + next.has(d.path) ? next.delete(d.path) : next.add(d.path); + return next; + }); + }; + + if (isLoading) + return ( +
+ +
+ ); + if (isError) + return ( +
+
{(error as Error).message}
+
+ Run whygraph scan to build the index. +
+
+ ); + + return ( +
+
+ Overview — click a directory to expand · coverage colored +
+ + + + +
+ ); +} diff --git a/src/playground/src/components/OverviewNode.tsx b/src/playground/src/components/OverviewNode.tsx new file mode 100644 index 0000000..5b48287 --- /dev/null +++ b/src/playground/src/components/OverviewNode.tsx @@ -0,0 +1,59 @@ +import { memo } from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { clsx } from "clsx"; + +// A LOD super-node: a directory or file, colored by rationale coverage. +export interface OverviewNodeData { + label: string; + kind: "directory" | "file"; + coverage: { analyzed: number; total: number; fraction: number }; + internal_edges: number; + [key: string]: unknown; +} + +function coverageColor(fraction: number, total: number): string { + if (total === 0) return "bg-slate-700"; + if (fraction === 0) return "bg-slate-600"; + if (fraction < 0.5) return "bg-amber-500"; + if (fraction < 1) return "bg-lime-500"; + return "bg-emerald-500"; +} + +function OverviewNodeInner({ data }: NodeProps) { + const d = data as OverviewNodeData; + const { analyzed, total, fraction } = d.coverage; + const isDir = d.kind === "directory"; + return ( +
+ +
+ {isDir ? "📁" : "📄"} + {d.label} +
+
+
+
+
+ + {analyzed}/{total} + +
+ {d.internal_edges > 0 && ( +
{d.internal_edges} internal
+ )} + +
+ ); +} + +export const OverviewNode = memo(OverviewNodeInner); diff --git a/src/playground/src/components/RationaleTab.tsx b/src/playground/src/components/RationaleTab.tsx new file mode 100644 index 0000000..2f18567 --- /dev/null +++ b/src/playground/src/components/RationaleTab.tsx @@ -0,0 +1,103 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, type RationaleCard } from "../api"; +import { Button, Spinner, EmptyState } from "../lib/ui"; + +// The Rationale tab (the resolved Q3 design): on open it does a CACHE-ONLY read +// (`GET`, never an LLM call). A cached card renders directly; otherwise a +// "Generate" button fires the `POST`, which runs the same MCP generation flow — +// so passive viewing never spends an LLM call, and the card can't drift. + +function BulletList({ title, items }: { title: string; items?: string[] }) { + if (!items || items.length === 0) return null; + return ( +
+
+ {title} +
+
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ ); +} + +function Card({ card }: { card: RationaleCard }) { + return ( +
+
+ Purpose +
+

{card.purpose}

+ +
+ Why it exists +
+

{card.why}

+ + + + + +
+ {card.provider} + {card.model ? ` · ${card.model}` : ""} + {card.cached_at ? ` · generated ${card.cached_at}` : ""} + {card.evidence_count && + ` · ${card.evidence_count.commits} commits, ${card.evidence_count.prs} PRs, ${card.evidence_count.issues} issues`} +
+
+ ); +} + +export function RationaleTab({ qualifiedName }: { qualifiedName: string }) { + const queryClient = useQueryClient(); + const queryKey = ["rationale", qualifiedName]; + + const { data, isLoading, isError, error } = useQuery({ + queryKey, + queryFn: () => api.rationaleRead(qualifiedName), + }); + + const generate = useMutation({ + mutationFn: () => api.rationaleGenerate(qualifiedName), + onSuccess: (card) => queryClient.setQueryData(queryKey, card), + }); + + if (isLoading) return
; + if (isError) + return Failed to load rationale: {(error as Error).message}; + + if (data?.status === "cached") return ; + + const noEvidence = data?.status === "no_evidence"; + + return ( +
+ {generate.isPending ? ( + + ) : ( + <> +

+ {noEvidence + ? "No historical evidence maps to this symbol, so a rationale can't be generated. Run `whygraph scan` to populate history." + : "No rationale has been generated for this symbol yet."} +

+ + {generate.isError && ( +

+ {(generate.error as Error).message} +

+ )} + + )} +
+ ); +} diff --git a/src/playground/src/components/RelationshipsTab.tsx b/src/playground/src/components/RelationshipsTab.tsx new file mode 100644 index 0000000..5c5d145 --- /dev/null +++ b/src/playground/src/components/RelationshipsTab.tsx @@ -0,0 +1,55 @@ +import type { NodeRelations, RelationSymbol, Symbol } from "../api"; +import { useExplorer } from "../store"; +import { KindBadge, EmptyState } from "../lib/ui"; + +// The Relationships tab: calls / called-by / imports / contained-by / children. +// Every row is a navigation target — clicking it fires the canonical openNode(). + +function Row({ symbol }: { symbol: RelationSymbol | Symbol }) { + const openNode = useExplorer((s) => s.openNode); + return ( + + ); +} + +function Section({ title, items }: { title: string; items: (RelationSymbol | Symbol)[] }) { + if (items.length === 0) return null; + return ( +
+
+ {title} ({items.length}) +
+ {items.map((s, i) => ( + + ))} +
+ ); +} + +export function RelationshipsTab({ relations }: { relations: NodeRelations }) { + const empty = + relations.callers.length === 0 && + relations.callees.length === 0 && + relations.imports.length === 0 && + relations.children.length === 0 && + !relations.container; + + if (empty) return No relationships recorded for this symbol.; + + return ( +
+ {relations.container &&
} +
+
+
+
+
+ ); +} diff --git a/src/playground/src/components/SymbolNode.tsx b/src/playground/src/components/SymbolNode.tsx new file mode 100644 index 0000000..3cd3b42 --- /dev/null +++ b/src/playground/src/components/SymbolNode.tsx @@ -0,0 +1,39 @@ +import { memo } from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { clsx } from "clsx"; +import { KindBadge } from "../lib/ui"; + +// A React Flow custom node for one symbol. Rendered at the server-supplied +// coordinates (no client-side layout). The focus node is visually emphasised. +export interface SymbolNodeData { + qualified_name: string; + name: string; + kind: string; + file_path: string; + is_focus: boolean; + [key: string]: unknown; +} + +function SymbolNodeInner({ data }: NodeProps) { + const d = data as SymbolNodeData; + return ( +
+ +
+ {d.name} + +
+
{d.file_path}
+ +
+ ); +} + +export const SymbolNode = memo(SymbolNodeInner); diff --git a/src/playground/src/components/Tree.tsx b/src/playground/src/components/Tree.tsx new file mode 100644 index 0000000..d8d18cb --- /dev/null +++ b/src/playground/src/components/Tree.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { clsx } from "clsx"; +import { api, type TreeEntry } from "../api"; +import { useExplorer } from "../store"; +import { KindBadge } from "../lib/ui"; + +// The left-hand containment tree: dir → file → class → method, lazy-loaded one +// level per expand. Expansion state is lifted to the root so `openNode()` from +// anywhere can auto-reveal the directory path to the selected symbol. + +function Chevron({ open }: { open: boolean }) { + return ( + + ▶ + + ); +} + +interface LevelProps { + dir?: string; + node?: string; + depth: number; + expanded: Set; + onToggle: (id: string) => void; +} + +function TreeLevel({ dir, node, depth, expanded, onToggle }: LevelProps) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["tree", { dir, node }], + queryFn: () => api.tree({ dir, node }), + }); + + if (isLoading) + return
; + if (isError) + return ( +
+ failed to load +
+ ); + + const entries = data?.entries ?? []; + if (entries.length === 0) + return ( +
+ (empty) +
+ ); + + return ( + <> + {entries.map((entry) => ( + + ))} + + ); +} + +function TreeRow({ + entry, + depth, + expanded, + onToggle, +}: { + entry: TreeEntry; + depth: number; + expanded: Set; + onToggle: (id: string) => void; +}) { + const selectedQn = useExplorer((s) => s.selectedQn); + const openNode = useExplorer((s) => s.openNode); + const isOpen = expanded.has(entry.id); + const isSelected = entry.qualified_name != null && entry.qualified_name === selectedQn; + const isDir = entry.kind === "directory"; + + const handleClick = () => { + if (entry.qualified_name) { + openNode(entry.qualified_name, entry.path); + if (entry.has_children) onToggle(entry.id); + } else if (entry.has_children) { + onToggle(entry.id); + } + }; + + return ( + <> +
+ {entry.has_children ? ( + (e.stopPropagation(), onToggle(entry.id))}> + + + ) : ( + + )} + {entry.label} + {!isDir && } +
+ {isOpen && entry.has_children && ( + + )} + + ); +} + +export function Tree() { + const [expanded, setExpanded] = useState>(new Set()); + const selectedFilePath = useExplorer((s) => s.selectedFilePath); + + const onToggle = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + // Auto-reveal: expand the directory chain down to the selected symbol's file. + useEffect(() => { + if (!selectedFilePath) return; + const parts = selectedFilePath.split("/"); + setExpanded((prev) => { + const next = new Set(prev); + let acc = ""; + for (let i = 0; i < parts.length - 1; i++) { + acc = acc ? `${acc}/${parts[i]}` : parts[i]; + next.add(`dir:${acc}`); + } + return next; + }); + }, [selectedFilePath]); + + return ( +
+
+ Explorer +
+
+ +
+
+ ); +} diff --git a/src/playground/src/index.css b/src/playground/src/index.css new file mode 100644 index 0000000..4938914 --- /dev/null +++ b/src/playground/src/index.css @@ -0,0 +1,41 @@ +@import "@xyflow/react/dist/style.css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: theme("colors.bg"); + color: theme("colors.fg"); + font-family: theme("fontFamily.sans"); + -webkit-font-smoothing: antialiased; +} + +/* React Flow surface tuned to the dark platform palette. */ +.react-flow__edge-path { + stroke-width: 1.5; +} +.react-flow__attribution { + display: none; +} + +/* Thin, unobtrusive scrollbars everywhere. */ +* { + scrollbar-width: thin; + scrollbar-color: theme("colors.border") transparent; +} +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} +*::-webkit-scrollbar-thumb { + background: theme("colors.border"); + border-radius: 4px; +} diff --git a/src/playground/src/lib/ui.tsx b/src/playground/src/lib/ui.tsx new file mode 100644 index 0000000..a7599a1 --- /dev/null +++ b/src/playground/src/lib/ui.tsx @@ -0,0 +1,76 @@ +import { clsx } from "clsx"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +// A small, self-contained set of Tailwind-styled primitives in the shadcn/Linear +// idiom — enough for the panel without pulling the full shadcn generator. + +const KIND_COLORS: Record = { + file: "bg-sky-500/15 text-sky-300 border-sky-500/30", + directory: "bg-slate-500/15 text-slate-300 border-slate-500/30", + class: "bg-violet-500/15 text-violet-300 border-violet-500/30", + method: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", + function: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", + variable: "bg-amber-500/15 text-amber-300 border-amber-500/30", + import: "bg-rose-500/15 text-rose-300 border-rose-500/30", +}; + +export function KindBadge({ kind }: { kind: string }) { + const cls = KIND_COLORS[kind] ?? "bg-slate-500/15 text-slate-300 border-slate-500/30"; + return ( + + {kind} + + ); +} + +export function CoverageDot({ analyzed }: { analyzed: boolean }) { + return ( + + ); +} + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "ghost"; + children: ReactNode; +} + +export function Button({ variant = "primary", className, children, ...rest }: ButtonProps) { + return ( + + ); +} + +export function Spinner({ label }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function EmptyState({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/playground/src/main.tsx b/src/playground/src/main.tsx new file mode 100644 index 0000000..e3bd829 --- /dev/null +++ b/src/playground/src/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import "./index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, + }, +}); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/src/playground/src/store.ts b/src/playground/src/store.ts new file mode 100644 index 0000000..b762aba --- /dev/null +++ b/src/playground/src/store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +// The single source of truth for "what symbol is open". Cmd-K, graph-node +// clicks, and relationship-list rows all call `openNode()` — the one canonical +// navigation entry point (§7.2). Everything else derives from `selectedQn`. +interface ExplorerState { + selectedQn: string | null; + // File path of the selected symbol when the caller knows it — lets the tree + // auto-reveal the containing directory path without an extra lookup. + selectedFilePath: string | null; + paletteOpen: boolean; + openNode: (qualifiedName: string, filePath?: string) => void; + setPaletteOpen: (open: boolean) => void; +} + +export const useExplorer = create((set) => ({ + selectedQn: null, + selectedFilePath: null, + paletteOpen: false, + openNode: (qualifiedName, filePath) => + set({ + selectedQn: qualifiedName, + selectedFilePath: filePath ?? null, + paletteOpen: false, + }), + setPaletteOpen: (open) => set({ paletteOpen: open }), +})); diff --git a/src/playground/tailwind.config.js b/src/playground/tailwind.config.js new file mode 100644 index 0000000..bc868e2 --- /dev/null +++ b/src/playground/tailwind.config.js @@ -0,0 +1,24 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + // A restrained slate/indigo "platform" palette (Linear/Vercel-ish). + bg: "#0b0d12", + panel: "#12151c", + panel2: "#171b24", + border: "#242a36", + muted: "#8b93a7", + fg: "#e6e9f0", + accent: "#6366f1", + accent2: "#818cf8", + }, + fontFamily: { + sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"], + mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"], + }, + }, + }, + plugins: [], +}; diff --git a/src/playground/tsconfig.json b/src/playground/tsconfig.json new file mode 100644 index 0000000..d109555 --- /dev/null +++ b/src/playground/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/src/playground/vite.config.ts b/src/playground/vite.config.ts new file mode 100644 index 0000000..e911f74 --- /dev/null +++ b/src/playground/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; + +// The built bundle is packed into the Python wheel (§9). Output straight into +// the package so `importlib`/FileResponse can serve it; `base: "/"` keeps asset +// URLs absolute, which the SPA catch-all in `serve/app.py` serves. +export default defineConfig({ + plugins: [react()], + base: "/", + build: { + // From src/playground/, `..` is src/, so this resolves to src/whygraph/serve/static. + outDir: fileURLToPath(new URL("../whygraph/serve/static", import.meta.url)), + emptyOutDir: true, + }, + server: { + // `npm run dev` proxies API calls to a running `whygraph serve` (§9.3 dev flow). + proxy: { + "/api": "http://localhost:8765", + }, + }, +}); diff --git a/src/whygraph/cli/__init__.py b/src/whygraph/cli/__init__.py index bbfdb10..631e5d8 100644 --- a/src/whygraph/cli/__init__.py +++ b/src/whygraph/cli/__init__.py @@ -18,6 +18,7 @@ from .commands.init import init_cmd from .commands.install import install_cmd from .commands.scan import scan_cmd +from .commands.serve import serve_cmd from .commands.version import version_cmd @@ -31,6 +32,7 @@ def main() -> None: main.add_command(version_cmd) main.add_command(init_cmd) main.add_command(scan_cmd) +main.add_command(serve_cmd) main.add_command(analyze_cmd) main.add_command(hooks_cmd) main.add_command(install_cmd) diff --git a/src/whygraph/cli/commands/install.py b/src/whygraph/cli/commands/install.py index 41f9c10..57c6af7 100644 --- a/src/whygraph/cli/commands/install.py +++ b/src/whygraph/cli/commands/install.py @@ -56,6 +56,55 @@ "$IMAGE" __NAME__ "$@" """ +# The `whygraph` shim carries a `serve` branch on top of the ephemeral path: +# every command runs ephemerally EXCEPT `whygraph serve`, which publishes a +# loopback port and manages a named, long-lived container. The shim (host shell) +# owns docker + lifecycle; the Python CLI inside the container owns only uvicorn. +# Port is fully shim-controlled via WHYGRAPH_PORT so `-p` and the in-container +# `--port` can never disagree — hence no `"$@"` passthrough on the serve path. +_WHYGRAPH_SHIM_TEMPLATE = """\ +#!/usr/bin/env sh +# WhyGraph shim. Every command runs ephemerally in the container EXCEPT +# `whygraph serve`, which publishes a port and manages the server container. +# Generated by `whygraph install`; safe to re-generate. +set -eu +IMAGE="${WHYGRAPH_IMAGE:-__IMAGE__}" + +if [ "${1:-}" = "serve" ]; then + NAME="whygraph-serve" + PORT="${WHYGRAPH_PORT:-8765}" + case "${2:-}" in + --stop) exec docker rm -f "$NAME" ;; + --logs) exec docker logs -f "$NAME" ;; + --help) exec docker run --rm "$IMAGE" whygraph serve --help ;; + --detach|-d) run="docker run -d" ;; + "") run="docker run --rm -i" ;; + *) echo "whygraph serve: unknown arg '$2' (port is set via WHYGRAPH_PORT)" >&2; exit 2 ;; + esac + # Clear any stale same-named container so a fresh start never collides. + docker rm -f "$NAME" >/dev/null 2>&1 || true + # `-p 127.0.0.1:` keeps it loopback-only; `--host 0.0.0.0` lets the forward + # reach uvicorn inside the container. Same --user/HOME + token passthrough + # as every other command. + exec $run --name "$NAME" -p "127.0.0.1:$PORT:$PORT" \\ + --user "$(id -u):$(id -g)" -e HOME=/tmp \\ + -v "$PWD:/workspace" -w /workspace \\ + -e GH_TOKEN -e GITHUB_TOKEN \\ + -e ANTHROPIC_API_KEY -e OPENAI_API_KEY -e DEEPSEEK_API_KEY \\ + "$IMAGE" whygraph serve --host 0.0.0.0 --port "$PORT" +fi + +# Every other command — unchanged ephemeral path, no port, no name. +tty="" +[ -t 0 ] && [ -t 1 ] && tty="-t" +exec docker run --rm -i $tty \\ + --user "$(id -u):$(id -g)" -e HOME=/tmp \\ + -v "$PWD:/workspace" -w /workspace \\ + -e GH_TOKEN -e GITHUB_TOKEN \\ + -e ANTHROPIC_API_KEY -e OPENAI_API_KEY -e DEEPSEEK_API_KEY \\ + "$IMAGE" whygraph "$@" +""" + _INSTALLER_TEMPLATE = """\ #!/usr/bin/env sh # WhyGraph installer — emitted by `whygraph install` from inside the image. @@ -88,7 +137,14 @@ def _shim(name: str, image: str) -> str: - """Render one shim's body for ``name``, baking ``image`` as its default.""" + """Render one shim's body for ``name``, baking ``image`` as its default. + + ``whygraph`` renders from :data:`_WHYGRAPH_SHIM_TEMPLATE` (carries the + ``serve`` branch); ``whygraph-mcp`` renders from the simple shared + :data:`_SHIM_TEMPLATE`. + """ + if name == "whygraph": + return _WHYGRAPH_SHIM_TEMPLATE.replace("__IMAGE__", image) return _SHIM_TEMPLATE.replace("__NAME__", name).replace("__IMAGE__", image) diff --git a/src/whygraph/cli/commands/serve.py b/src/whygraph/cli/commands/serve.py new file mode 100644 index 0000000..6355af8 --- /dev/null +++ b/src/whygraph/cli/commands/serve.py @@ -0,0 +1,39 @@ +"""The ``whygraph serve`` subcommand — run the Explorer panel HTTP server. + +Deliberately "dumb": it only ever runs a **foreground uvicorn** bound to a socket. +It knows nothing about Docker, port forwarding, or container lifecycle — the +``whygraph`` shim's ``serve`` branch owns all of that (it publishes the port and +manages ``--detach`` / ``--stop`` / ``--logs``). This command, running *inside* the +container, is simply the server. + +``--host`` defaults to ``127.0.0.1`` (safe for the native ``uv tool install`` path, +which has no container boundary); the shim passes ``--host 0.0.0.0`` for the +container so Docker's ``-p 127.0.0.1:PORT:PORT`` forward can reach uvicorn. +""" + +from __future__ import annotations + +import click + + +@click.command(name="serve") +@click.option("--port", default=8765, show_default=True, help="Port to bind.") +@click.option( + "--host", + default="127.0.0.1", + show_default=True, + help="Bind address (the shim passes 0.0.0.0 for the container).", +) +def serve_cmd(port: int, host: str) -> None: + """Serve the WhyGraph Explorer panel for this repository.""" + # Lazy-imported so `--help` stays fast and doesn't require the HTTP stack. + import uvicorn + + from whygraph.core import get_config + from whygraph.serve.app import create_app + + from ..console import console + + app = create_app(get_config()) + console.print(f"[bold]WhyGraph Explorer[/] → http://localhost:{port}") + uvicorn.run(app, host=host, port=port, log_config=None) diff --git a/src/whygraph/serve/__init__.py b/src/whygraph/serve/__init__.py new file mode 100644 index 0000000..1258fc1 --- /dev/null +++ b/src/whygraph/serve/__init__.py @@ -0,0 +1,22 @@ +"""The WhyGraph Explorer HTTP server — a second transport over the service layer. + +``whygraph serve`` (see :mod:`whygraph.cli.commands.serve`) runs the FastAPI app +built here as a long-lived, loopback-only container. The app is a **thin adapter**: +its ``/api`` routes call the *same* plain functions the MCP tools call +(:func:`whygraph.mcp.rationale.whygraph_rationale_brief`, +:func:`whygraph.mcp.evidence.whygraph_evidence_for`, +:func:`whygraph.mcp.area_history.whygraph_area_history`, the resource readers) plus +the kind-aware traversal methods on +:class:`whygraph.services.codegraph.CodeGraph`, so the panel's rationale / evidence +/ history can never drift from the MCP's. + +The panel is **read-only** except for one explicit, user-initiated action: the +``POST .../rationale`` endpoint, which generates and caches a rationale card exactly +as the MCP tool does. Passive viewing never calls an LLM. + +Public API +---------- +* :func:`whygraph.serve.app.create_app` — the FastAPI application factory. +""" + +from __future__ import annotations diff --git a/src/whygraph/serve/app.py b/src/whygraph/serve/app.py new file mode 100644 index 0000000..517bcbf --- /dev/null +++ b/src/whygraph/serve/app.py @@ -0,0 +1,100 @@ +"""FastAPI application factory for the Explorer panel. + +:func:`create_app` wires the ``/api`` router (:mod:`whygraph.serve.routes`) onto a +FastAPI instance, translates the shared :class:`WhyGraphError` into HTTP responses, +and serves the built React bundle from ``static/`` with an SPA fallback. + +The bundle is gitignored and produced only at build time (Docker ``COPY --from`` or +the hatch build hook), so a **source checkout** may have no ``static/``. The factory +must not crash in that case: it serves ``/api`` normally and returns a short +"UI not built" message at ``/`` (see :func:`_mount_static`). +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles + +from whygraph.core.config import Config +from whygraph.db import ensure_initialized +from whygraph.mcp.errors import WhyGraphError + +from .routes import router + +_STATIC_DIR = Path(__file__).resolve().parent / "static" +_NOT_BUILT_MESSAGE = ( + "WhyGraph Explorer UI is not built.\n\n" + "This is a source checkout with no static bundle. Build it with:\n" + " make playground\n" + " # or: npm --prefix src/playground ci && npm --prefix src/playground run build\n\n" + "The /api endpoints are available and working." +) + + +def create_app(config: Config) -> FastAPI: + """Build the Explorer FastAPI app for the current repository. + + Parameters + ---------- + config : Config + The resolved WhyGraph config (currently unused by the routes, which pull + config lazily per request, but threaded through so the factory owns the + config binding and future settings have a home). + + Returns + ------- + FastAPI + The configured application, ready for ``uvicorn.run``. + """ + ensure_initialized() + app = FastAPI(title="WhyGraph Explorer", docs_url=None, redoc_url=None) + + @app.exception_handler(WhyGraphError) + def _whygraph_error_handler(_: Request, exc: WhyGraphError) -> JSONResponse: + # A "not found" rejection maps to 404; every other WhyGraphError is a + # bad-request-shaped failure (invalid target, unscanned DB message, …). + status = 404 if "not found" in str(exc).lower() else 400 + return JSONResponse(status_code=status, content={"error": str(exc)}) + + app.include_router(router, prefix="/api") + _mount_static(app) + return app + + +def _mount_static(app: FastAPI) -> None: + """Serve the built SPA from ``static/`` with a client-routing fallback. + + When the bundle is absent (source checkout), install a placeholder ``/`` route + instead so the server still starts and ``/api`` keeps working. + """ + index = _STATIC_DIR / "index.html" + if not index.is_file(): + + @app.get("/") + def _ui_missing() -> PlainTextResponse: + return PlainTextResponse(_NOT_BUILT_MESSAGE) + + return + + # Real bundle: serve any built asset by path, else fall back to index.html so + # client-side routes resolve. Declared after the /api router, so /api wins. + @app.get("/{full_path:path}") + def _spa(full_path: str) -> FileResponse: + candidate = _STATIC_DIR / full_path + if ( + full_path + and candidate.is_file() + and _STATIC_DIR in candidate.resolve().parents + ): + return FileResponse(candidate) + return FileResponse(index) + + # Keep StaticFiles available for a conventional /static prefix too (harmless + # if the bundle references absolute /assets paths, which the catch-all serves). + if (_STATIC_DIR / "assets").is_dir(): + app.mount( + "/assets", StaticFiles(directory=_STATIC_DIR / "assets"), name="assets" + ) diff --git a/src/whygraph/serve/coverage.py b/src/whygraph/serve/coverage.py new file mode 100644 index 0000000..0cd4daf --- /dev/null +++ b/src/whygraph/serve/coverage.py @@ -0,0 +1,55 @@ +"""Phase-2 rationale-coverage counting for the LOD overview heatmap. + +"Analyzed" for a symbol means a ``rationale_cache`` row whose +``(path, line_start, line_end)`` matches the symbol's ``file_path`` + +``start_line`` / ``end_line`` — the cache is keyed by line range, **not** by +qualified_name (§2.6/§7.3). Per-file coverage is the fraction of a file's +definable symbols that have such a match; per-directory coverage aggregates its +files. Because the cache is populated lazily (only when a user clicks Generate), +most symbols read "unexplored" until browsed — which is the point of the heatmap. +""" + +from __future__ import annotations + +from sqlmodel import select + +from whygraph.db import get_session +from whygraph.db.models import RationaleCache +from whygraph.services.codegraph import CodeGraph + + +def _analyzed_keys() -> set[tuple[str, int, int]]: + """The ``(path, line_start, line_end)`` of every cached rationale row.""" + with get_session() as session: + rows = session.exec( + select( + RationaleCache.path, + RationaleCache.line_start, + RationaleCache.line_end, + ) + ).all() + return {(r[0], r[1], r[2]) for r in rows} + + +def file_coverage(graph: CodeGraph) -> dict[str, tuple[int, int]]: + """Per-file ``(analyzed, total)`` counts over definable symbols. + + Parameters + ---------- + graph : CodeGraph + An open graph handle. + + Returns + ------- + dict + ``file_path -> (analyzed_count, total_count)``. A file with no definable + symbols is absent from the mapping. + """ + analyzed = _analyzed_keys() + counts: dict[str, list[int]] = {} + for file_path, start, end in graph.definition_ranges(): + entry = counts.setdefault(file_path, [0, 0]) + entry[1] += 1 # total + if (file_path, start, end) in analyzed: + entry[0] += 1 # analyzed + return {path: (a, t) for path, (a, t) in counts.items()} diff --git a/src/whygraph/serve/graphdata.py b/src/whygraph/serve/graphdata.py new file mode 100644 index 0000000..e90c744 --- /dev/null +++ b/src/whygraph/serve/graphdata.py @@ -0,0 +1,239 @@ +"""Server-side graph + tree assembly for the Explorer panel. + +Two jobs, both computed in Python so the browser never runs a layout engine +(the direct fix for the old viewer being "slow and glitchy"): + +* :func:`ego_graph` — the focus symbol plus its immediate typed neighbours + (callers, callees, imports, container, children), rendered as React-Flow-ready + ``nodes`` / ``edges`` with **precomputed layered coordinates**. The browser only + pans, zooms, and re-fetches on expansion. +* :func:`tree_level` — one lazy level of the ``dir → file → class → method`` + containment tree, with directories synthesised from file-node ``file_path``. + +Both take an open :class:`~whygraph.services.codegraph.CodeGraph`; neither opens or +closes it (the caller owns the per-request handle — see :mod:`whygraph.serve.routes`). +""" + +from __future__ import annotations + +from whygraph.services.codegraph import CodeGraph, Relation, Symbol + +# Layout constants for the layered ego-graph. Rows are stacked vertically; nodes +# within a row are spread horizontally and centred on the focus at (0, 0). +_ROW_GAP = 170 +_COL_GAP = 240 + +# Symbol kinds that may contain other symbols — used to decide, cheaply and +# without an extra query per row, whether a tree node shows an expand affordance. +_EXPANDABLE_KINDS = {"file", "class", "module", "namespace", "interface"} + + +def _symbol_dict(symbol: Symbol) -> dict: + """The identity fields the UI needs for a symbol, in one flat dict.""" + return { + "id": symbol.id, + "qualified_name": symbol.qualified_name, + "name": symbol.name, + "kind": symbol.kind, + "file_path": symbol.file_path, + "start_line": symbol.start_line, + "end_line": symbol.end_line, + "signature": symbol.signature, + } + + +def _relation_dict(relation: Relation) -> dict: + """A relationship-list row: the neighbour symbol plus the edge kind/line.""" + return { + **_symbol_dict(relation.symbol), + "edge_kind": relation.kind, + "edge_line": relation.line, + } + + +def node_relations(graph: CodeGraph, symbol: Symbol) -> dict: + """Every typed relationship of ``symbol``, grouped for the detail panel. + + Parameters + ---------- + graph : CodeGraph + An open graph handle. + symbol : Symbol + The resolved focus symbol. + + Returns + ------- + dict + ``{callers, callees, imports, container, children}`` — each a list of + relation/symbol dicts (``container`` is a single dict or ``None``). + """ + container = graph.container(symbol.id) + return { + "callers": [_relation_dict(r) for r in graph.callers(symbol.id)], + "callees": [_relation_dict(r) for r in graph.callees(symbol.id)], + "imports": [_relation_dict(r) for r in graph.imports_(symbol.id)], + "container": _symbol_dict(container) if container else None, + "children": [_symbol_dict(s) for s in graph.children(symbol.id)], + } + + +def _row_positions(count: int, y: float) -> list[dict]: + """``count`` positions on row ``y``, centred on x = 0.""" + return [{"x": (i - (count - 1) / 2) * _COL_GAP, "y": y} for i in range(count)] + + +def ego_graph(graph: CodeGraph, symbol: Symbol) -> dict: + """Assemble the one-hop ego graph of ``symbol`` with layered coordinates. + + Layout is three rows: things that point *into* / contain the focus above it + (callers, container), the focus in the middle, and things it points *at* / + contains below it (callees, imports, children). Coordinates are final — the + client renders them verbatim, never running a force simulation. + + Parameters + ---------- + graph : CodeGraph + An open graph handle. + symbol : Symbol + The resolved focus symbol. + + Returns + ------- + dict + ``{focus, nodes, edges}``. ``nodes`` carry ``position`` + ``data``; + ``edges`` carry ``source`` / ``target`` (CodeGraph node ids) and ``kind``. + A duplicate neighbour (e.g. a symbol that both calls and is called by the + focus) appears once as a node but keeps both directed edges. + """ + container = graph.container(symbol.id) + callers = graph.callers(symbol.id) + callees = graph.callees(symbol.id) + imports = graph.imports_(symbol.id) + children = graph.children(symbol.id) + + # Upper row: callers + the container. Lower row: callees + imports + children. + upper: list[tuple[Symbol, str, bool]] = [(r.symbol, "calls", True) for r in callers] + if container is not None: + upper.append((container, "contains", True)) + lower: list[tuple[Symbol, str, bool]] = ( + [(r.symbol, "calls", False) for r in callees] + + [(r.symbol, "imports", False) for r in imports] + + [(s, "contains", False) for s in children] + ) + + nodes: dict[str, dict] = { + symbol.id: { + "id": symbol.id, + "position": {"x": 0.0, "y": 0.0}, + "data": {**_symbol_dict(symbol), "is_focus": True}, + } + } + edges: list[dict] = [] + + def _place(items: list[tuple[Symbol, str, bool]], y: float) -> None: + for (neighbour, kind, incoming), pos in zip( + items, _row_positions(len(items), y) + ): + if neighbour.id not in nodes: + nodes[neighbour.id] = { + "id": neighbour.id, + "position": pos, + "data": {**_symbol_dict(neighbour), "is_focus": False}, + } + src, tgt = ( + (neighbour.id, symbol.id) if incoming else (symbol.id, neighbour.id) + ) + edges.append( + { + "id": f"{src}->{tgt}:{kind}", + "source": src, + "target": tgt, + "kind": kind, + } + ) + + _place(upper, -_ROW_GAP) + _place(lower, _ROW_GAP) + + return { + "focus": symbol.qualified_name, + "nodes": list(nodes.values()), + "edges": edges, + } + + +def _tree_entry_for_symbol(symbol: Symbol) -> dict: + """A containment-tree row for a symbol node (file / class / method / …).""" + return { + "id": f"node:{symbol.id}", + "label": symbol.name, + "kind": symbol.kind, + "node_id": symbol.id, + "qualified_name": symbol.qualified_name, + "path": symbol.file_path, + "has_children": symbol.kind in _EXPANDABLE_KINDS, + } + + +def _tree_entry_for_dir(path: str, label: str) -> dict: + """A containment-tree row for a synthesised directory.""" + return { + "id": f"dir:{path}", + "label": label, + "kind": "directory", + "dir": path, + "has_children": True, + } + + +def tree_level( + graph: CodeGraph, + *, + directory: str | None = None, + node_id: str | None = None, +) -> list[dict]: + """Return one lazy level of the containment tree. + + Exactly one expansion mode applies: + + * ``node_id`` given — the symbol children of that file/class node + (``CodeGraph.children``). + * otherwise — the entries directly under ``directory`` (root when ``None``): + immediate sub-directories (synthesised from file ``file_path``) then the + file nodes that live directly in it. + + Parameters + ---------- + graph : CodeGraph + An open graph handle. + directory : str, optional + Directory path to list, relative to the repo root. ``None`` lists root. + node_id : str, optional + A file/class node id whose symbol children to list. Wins over + ``directory`` when both are given. + + Returns + ------- + list[dict] + Directory rows first, then file/symbol rows. + """ + if node_id is not None: + return [_tree_entry_for_symbol(s) for s in graph.children(node_id)] + + prefix = f"{directory.rstrip('/')}/" if directory else "" + subdirs: dict[str, None] = {} # ordered set of immediate sub-directory names + files: list[Symbol] = [] + for file_symbol in graph.files(): + file_path = file_symbol.file_path + if not file_path.startswith(prefix): + continue + rest = file_path[len(prefix) :] + head, _, tail = rest.partition("/") + if tail: + subdirs.setdefault(head, None) + else: + files.append(file_symbol) + + dir_entries = [_tree_entry_for_dir(f"{prefix}{name}", name) for name in subdirs] + file_entries = [_tree_entry_for_symbol(f) for f in files] + return dir_entries + file_entries diff --git a/src/whygraph/serve/lifting.py b/src/whygraph/serve/lifting.py new file mode 100644 index 0000000..4be4203 --- /dev/null +++ b/src/whygraph/serve/lifting.py @@ -0,0 +1,131 @@ +"""Phase-2 edge-lifting for the directory-level LOD overview (§8). + +Every low-level ``calls`` / ``imports`` edge is projected onto the **deepest +currently-visible ancestor** of each endpoint, given an ``expanded`` set of +directory paths. Cross-container edges become one **weighted, directional** edge +between super-nodes; edges internal to a collapsed super-node are hidden (counted +as ``internal_edges`` on the node). Lifting for a given expansion state is a cheap +group-by over :meth:`CodeGraph.file_edges` — no per-request graph walk. +""" + +from __future__ import annotations + +from whygraph.services.codegraph import CodeGraph + +# The low-level edge kinds rolled up into the overview. `contains` is the tree +# structure itself, so it is deliberately excluded. +LIFTED_KINDS = ("calls", "imports") + + +def _dirs_of(file_path: str) -> list[str]: + """Cumulative directory paths of ``file_path``, shallowest first. + + ``"a/b/c/foo.py"`` → ``["a", "a/b", "a/b/c"]`` (the filename is dropped). + """ + parts = file_path.split("/")[:-1] + dirs: list[str] = [] + acc = "" + for part in parts: + acc = f"{acc}/{part}" if acc else part + dirs.append(acc) + return dirs + + +def _representative(file_path: str, expanded: set[str]) -> str: + """The visible node id that ``file_path`` rolls up to under ``expanded``. + + Descends the directory chain through expanded ancestors; the first collapsed + directory (its parent expanded, itself not) is the super-node. If the whole + chain is expanded, the file itself is the visible node. + """ + for directory in _dirs_of(file_path): + if directory not in expanded: + return f"dir:{directory}" + return f"file:{file_path}" + + +def _rep_coverage(rep: str, coverage: dict[str, tuple[int, int]]) -> dict: + """Aggregate ``(analyzed, total)`` coverage for a representative node.""" + if rep.startswith("file:"): + analyzed, total = coverage.get(rep[len("file:") :], (0, 0)) + else: + prefix = rep[len("dir:") :] + "/" + analyzed = total = 0 + for file_path, (file_analyzed, file_total) in coverage.items(): + if file_path.startswith(prefix): + analyzed += file_analyzed + total += file_total + return { + "analyzed": analyzed, + "total": total, + "fraction": analyzed / total if total else 0.0, + } + + +def _node_meta(rep: str) -> dict: + """Identity fields for a representative node (kind, label, path).""" + if rep.startswith("file:"): + path = rep[len("file:") :] + return {"id": rep, "kind": "file", "label": path.split("/")[-1], "path": path} + path = rep[len("dir:") :] + return {"id": rep, "kind": "directory", "label": path.split("/")[-1], "path": path} + + +def build_overview( + graph: CodeGraph, + expanded: set[str], + coverage: dict[str, tuple[int, int]], +) -> dict: + """Assemble the LOD overview graph for a given expansion state. + + Parameters + ---------- + graph : CodeGraph + An open graph handle. + expanded : set of str + Directory paths that are currently expanded (top-level dirs are always + visible regardless). + coverage : dict + ``file_path -> (analyzed, total)`` from :func:`coverage.file_coverage`. + + Returns + ------- + dict + ``{"expanded": [...], "nodes": [...], "edges": [...]}``. Nodes carry a + ``coverage`` block and an ``internal_edges`` count; edges are weighted and + directional (``X→Y`` and ``Y→X`` stay distinct — asymmetry is signal). + """ + # Visible node set: the representative of every file under the current state. + nodes: dict[str, dict] = {} + for file_symbol in graph.files(): + rep = _representative(file_symbol.file_path, expanded) + nodes.setdefault(rep, {**_node_meta(rep), "internal_edges": 0}) + + weights: dict[tuple[str, str, str], int] = {} + for src_file, tgt_file, kind in graph.file_edges(LIFTED_KINDS): + src_rep = _representative(src_file, expanded) + tgt_rep = _representative(tgt_file, expanded) + # An edge may touch a file with no file node listed (defensive): make sure + # both endpoints are present as nodes. + nodes.setdefault(src_rep, {**_node_meta(src_rep), "internal_edges": 0}) + nodes.setdefault(tgt_rep, {**_node_meta(tgt_rep), "internal_edges": 0}) + if src_rep == tgt_rep: + nodes[src_rep]["internal_edges"] += 1 + continue + key = (src_rep, tgt_rep, kind) + weights[key] = weights.get(key, 0) + 1 + + for node in nodes.values(): + node["coverage"] = _rep_coverage(node["id"], coverage) + + edges = [ + { + "id": f"{src}->{tgt}:{kind}", + "source": src, + "target": tgt, + "kind": kind, + "weight": weight, + } + for (src, tgt, kind), weight in weights.items() + ] + return {"expanded": sorted(expanded), "nodes": list(nodes.values()), "edges": edges} diff --git a/src/whygraph/serve/routes.py b/src/whygraph/serve/routes.py new file mode 100644 index 0000000..877aebf --- /dev/null +++ b/src/whygraph/serve/routes.py @@ -0,0 +1,237 @@ +"""The ``/api/*`` routes for the Explorer panel. + +Every handler is a **sync** ``def`` so FastAPI runs it in the threadpool — each +request gets its own thread, its own ``get_session()`` (a ``sqlmodel.Session`` is +not thread-safe), and its own read-only :class:`CodeGraph` handle. Handlers stay +thin: they delegate to the shared service functions +(:mod:`whygraph.mcp.rationale` / ``evidence`` / ``area_history`` / ``resources``) +and to the new traversal methods on :class:`CodeGraph`, then serialise. + +Rationale is split (the resolved design decision): the **GET** is LLM-free — it +resolves the target, collects evidence, and reads the cache — while the **POST** +runs the full ``whygraph_rationale_brief`` generate-and-cache flow. Generation +therefore happens only on the explicit "Generate" button, never on passive view. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Iterator + +from fastapi import APIRouter, HTTPException, Query + +from whygraph.core import get_config +from whygraph.mcp.area_history import whygraph_area_history +from whygraph.mcp.evidence import collect_evidence, whygraph_evidence_for +from whygraph.mcp.rationale import _format_response, whygraph_rationale_brief +from whygraph.mcp.rationale_cache import lookup_cached +from whygraph.mcp.resources import _commit_resource, _issue_resource, _pr_resource +from whygraph.mcp.targets import repo_root, resolve_target, target_dict +from whygraph.services.codegraph import CodeGraph, CodeGraphError + +from . import graphdata + +router = APIRouter() + + +@contextmanager +def _open_graph() -> Iterator[CodeGraph]: + """Open a per-request read-only CodeGraph handle, or 503 if there is none. + + A missing/unopenable ``.codegraph/`` DB is a setup failure (the user must run + ``whygraph scan``), surfaced as HTTP 503 so the UI can show a clear banner + rather than a 500. + """ + try: + graph = CodeGraph.for_repository( + repo_root(), codegraph_db=get_config().codegraph_db + ) + except CodeGraphError as exc: + raise HTTPException( + status_code=503, + detail=f"CodeGraph index unavailable — run `whygraph scan`: {exc}", + ) from exc + try: + yield graph + finally: + graph.close() + + +def _coverage_flag(symbol_file: str, start: int, end: int) -> bool: + """Whether a cached rationale row matches this symbol's (path, line range). + + The rationale cache is keyed by line range, not qualified_name (§7.3), so a + match means the symbol has been analysed. LLM-free — a pure cache read. + """ + from whygraph.db import get_session + from whygraph.db.models import RationaleCache + from sqlmodel import select + + with get_session() as session: + row = session.exec( + select(RationaleCache.path) + .where(RationaleCache.path == symbol_file) + .where(RationaleCache.line_start == start) + .where(RationaleCache.line_end == end) + ).first() + return row is not None + + +# ---- discovery ----------------------------------------------------------- + + +@router.get("/search") +def search(q: str = Query(""), limit: int = Query(20, ge=1, le=100)) -> dict: + """Cmd-K search — symbols whose name/qualified name contains ``q``.""" + if not q: + return {"query": q, "results": []} + with _open_graph() as graph: + symbols = graph.search(q, limit=limit) + results = [ + { + **graphdata._symbol_dict(s), + "analyzed": _coverage_flag(s.file_path, s.start_line, s.end_line), + } + for s in symbols + ] + return {"query": q, "results": results} + + +@router.get("/tree") +def tree( + dir: str | None = Query(None), + node: str | None = Query(None), +) -> dict: + """One lazy level of the containment tree (root when no params).""" + with _open_graph() as graph: + entries = graphdata.tree_level(graph, directory=dir, node_id=node) + return {"dir": dir, "node": node, "entries": entries} + + +# ---- graph --------------------------------------------------------------- + + +@router.get("/graph/overview") +def graph_overview(expanded: str = Query("")) -> dict: + """Phase-2 LOD overview: directory super-nodes with weighted lifted edges. + + ``expanded`` is a comma-separated list of expanded directory paths (empty → + only top-level directories). The landing view of the panel. + """ + from . import coverage, lifting + + expanded_set = {d for d in expanded.split(",") if d} + with _open_graph() as graph: + cov = coverage.file_coverage(graph) + return lifting.build_overview(graph, expanded_set, cov) + + +@router.get("/graph/ego") +def graph_ego( + qualified_name: str = Query(...), hops: int = Query(1, ge=1, le=1) +) -> dict: + """The one-hop ego graph of a symbol, with server-computed coordinates.""" + with _open_graph() as graph: + symbol = graph.symbol(qualified_name) + if symbol is None: + raise HTTPException(status_code=404, detail=f"{qualified_name!r} not found") + return graphdata.ego_graph(graph, symbol) + + +# ---- node detail --------------------------------------------------------- + + +@router.get("/node/{qualified_name}") +def node_detail(qualified_name: str) -> dict: + """Identity + typed relationships for a symbol (the Relationships tab).""" + with _open_graph() as graph: + symbol = graph.symbol(qualified_name) + if symbol is None: + raise HTTPException(status_code=404, detail=f"{qualified_name!r} not found") + return { + "symbol": graphdata._symbol_dict(symbol), + "analyzed": _coverage_flag( + symbol.file_path, symbol.start_line, symbol.end_line + ), + "relations": graphdata.node_relations(graph, symbol), + } + + +@router.get("/node/{qualified_name}/rationale") +def rationale_read(qualified_name: str) -> dict: + """Cache-only rationale read — never calls an LLM (the resolved Q3 split). + + Returns ``{status: "cached", ...card}`` on a cache hit, + ``{status: "no_evidence"}`` when the target has no historical evidence, or + ``{status: "not_generated"}`` otherwise — the signal the UI uses to show a + "Generate" button. + """ + target = resolve_target( + path=None, line_start=None, line_end=None, qualified_name=qualified_name + ) + evidence = collect_evidence(target, limit=20) + if not evidence: + return {"status": "no_evidence", "target": target_dict(target)} + cfg = get_config().rationale + cached = lookup_cached(target, evidence, cfg.provider, cfg.model) + if cached is None: + return {"status": "not_generated", "target": target_dict(target)} + rationale, cached_at = cached + return { + "status": "cached", + **_format_response(target, rationale, evidence, cached_at), + } + + +@router.post("/node/{qualified_name}/rationale") +def rationale_generate(qualified_name: str) -> dict: + """Generate + cache a rationale card (the explicit "Generate" action). + + Runs :func:`whygraph_rationale_brief` verbatim — the same generate-and-cache + flow the MCP tool performs — so the card can never drift from the MCP's. Slow + (one LLM call); runs in the threadpool so the event loop is not blocked. + """ + card = whygraph_rationale_brief(qualified_name=qualified_name) + return {"status": "cached", **card} + + +@router.get("/node/{qualified_name}/evidence") +def evidence(qualified_name: str, limit: int = Query(20, ge=1, le=100)) -> dict: + """Historical evidence for a symbol (the Evidence tab).""" + return whygraph_evidence_for(qualified_name=qualified_name, limit=limit) + + +# ---- history (path-keyed; query param avoids a slash-in-path converter) --- + + +@router.get("/history") +def history( + path: str = Query(...), + limit: int = Query(20, ge=1, le=100), + include_renames: bool = Query(True), +) -> dict: + """Area history for a file path (the History tab).""" + return whygraph_area_history( + path=path, limit=limit, include_renames=include_renames + ) + + +# ---- evidence-link detail ------------------------------------------------ + + +@router.get("/commit/{sha}") +def commit(sha: str) -> dict: + """A commit and the PRs that contain it (mirrors the MCP resource).""" + return _commit_resource(sha) + + +@router.get("/pr/{number}") +def pull_request(number: int) -> dict: + """A pull request and the issues it closes (mirrors the MCP resource).""" + return _pr_resource(number) + + +@router.get("/issue/{number}") +def issue(number: int) -> dict: + """An issue and the PRs that close it (mirrors the MCP resource).""" + return _issue_resource(number) diff --git a/src/whygraph/services/codegraph/graph.py b/src/whygraph/services/codegraph/graph.py index 44407f8..85a4957 100644 --- a/src/whygraph/services/codegraph/graph.py +++ b/src/whygraph/services/codegraph/graph.py @@ -32,6 +32,14 @@ # The edge kind that records one symbol invoking another. _CALLS = "calls" +# The edge kind that records one symbol importing another. +_IMPORTS = "imports" +# The edge kind that records structural containment (file → class → method). +_CONTAINS = "contains" +# The node kind for a source file — the roots of the containment tree. +_FILE = "file" +# Node kinds a rationale card can be generated for — used by coverage counting. +_DEFINABLE_KINDS = ("function", "method", "class") class CodeGraph: @@ -204,7 +212,7 @@ def callers(self, node_id: str) -> list[Relation]: One :class:`Relation` per incoming ``calls`` edge; each :attr:`Relation.symbol` is a caller. Empty when nothing calls it. """ - return self._calls_relations(node_id, incoming=True) + return self.relations(node_id, _CALLS, incoming=True) def callees(self, node_id: str) -> list[Relation]: """Symbols the given symbol calls — its fan-out. @@ -220,15 +228,32 @@ def callees(self, node_id: str) -> list[Relation]: One :class:`Relation` per outgoing ``calls`` edge; each :attr:`Relation.symbol` is a callee. Empty when it calls nothing. """ - return self._calls_relations(node_id, incoming=False) + return self.relations(node_id, _CALLS, incoming=False) - def _calls_relations(self, node_id: str, *, incoming: bool) -> list[Relation]: - """Resolve the ``calls`` edges on one side of a symbol. + def relations(self, node_id: str, kind: str, *, incoming: bool) -> list[Relation]: + """Resolve the edges of one ``kind`` on one side of a symbol. - ``incoming`` selects callers — the edge ``target`` is ``node_id`` and - the neighbour is the edge ``source``; otherwise callees, the mirror. - The edge's ``kind`` and ``line`` are aliased to ``edge_kind`` / - ``edge_line`` so they do not collide with the node's own ``kind``. + Generalises callers/callees to any edge ``kind`` (``calls``, + ``imports``, ``contains``). ``incoming`` selects the edges whose + ``target`` is ``node_id`` (the neighbour is then the edge ``source``); + otherwise the mirror — edges whose ``source`` is ``node_id``. The + edge's ``kind`` and ``line`` are aliased to ``edge_kind`` / ``edge_line`` + so they do not collide with the node's own ``kind``. + + Parameters + ---------- + node_id : str + The :attr:`Symbol.id` to anchor the traversal on. + kind : str + The edge kind to filter on, e.g. ``"calls"`` or ``"contains"``. + incoming : bool + When ``True``, return edges pointing *at* ``node_id`` (fan-in); + when ``False``, edges pointing *away* from it (fan-out). + + Returns + ------- + list[Relation] + One :class:`Relation` per matching edge; empty when there are none. """ anchor, neighbour = ("target", "source") if incoming else ("source", "target") rows = self._conn.execute( @@ -236,10 +261,133 @@ def _calls_relations(self, node_id: str, *, incoming: bool) -> list[Relation]: "e.kind AS edge_kind, e.line AS edge_line " f"FROM edges e JOIN nodes n ON n.id = e.{neighbour} " f"WHERE e.{anchor} = ? AND e.kind = ?", - (node_id, _CALLS), + (node_id, kind), ).fetchall() return [Relation.from_row(r) for r in rows] + def imports_(self, node_id: str) -> list[Relation]: + """Symbols the given symbol imports — its outgoing ``imports`` edges. + + Parameters + ---------- + node_id : str + The :attr:`Symbol.id` of the importing symbol. + + Returns + ------- + list[Relation] + One :class:`Relation` per outgoing ``imports`` edge; empty when it + imports nothing. + """ + return self.relations(node_id, _IMPORTS, incoming=False) + + def container(self, node_id: str) -> Symbol | None: + """The symbol that structurally contains the given symbol. + + A ``file`` node *contains* a ``class``; a ``class`` *contains* a + ``method``. The parent is therefore the source of an **incoming** + ``contains`` edge (this symbol is the target). + + Parameters + ---------- + node_id : str + The :attr:`Symbol.id` of the contained symbol. + + Returns + ------- + Symbol or None + The containing symbol, or ``None`` when the symbol has no + ``contains`` parent (e.g. a ``file`` node). + """ + rels = self.relations(node_id, _CONTAINS, incoming=True) + return rels[0].symbol if rels else None + + def children(self, node_id: str) -> list[Symbol]: + """The symbols the given symbol structurally contains. + + The mirror of :meth:`container` — the targets of **outgoing** + ``contains`` edges (a file's classes/functions, a class's methods). + + Parameters + ---------- + node_id : str + The :attr:`Symbol.id` of the containing symbol. + + Returns + ------- + list[Symbol] + The contained symbols, in edge order; empty when it contains none. + """ + return [r.symbol for r in self.relations(node_id, _CONTAINS, incoming=False)] + + def files(self) -> list[Symbol]: + """All ``file`` nodes — the roots of the containment tree. + + Returns + ------- + list[Symbol] + Every symbol whose ``kind`` is ``"file"``, ordered by ``file_path``. + """ + rows = self._conn.execute( + f"{_NODE_SELECT} WHERE kind = ? ORDER BY file_path", + (_FILE,), + ).fetchall() + return [Symbol.from_row(r) for r in rows] + + def file_edges(self, kinds: tuple[str, ...]) -> list[tuple[str, str, str]]: + """Every edge of the given kinds, projected onto endpoint file paths. + + Joins each edge's ``source`` and ``target`` to their nodes and returns + the *defining file paths* of both ends. The Phase-2 edge-lifting + aggregation (:mod:`whygraph.serve.lifting`) group-bys over these to roll + low-level ``calls`` / ``imports`` edges up to directory/file super-nodes. + + Parameters + ---------- + kinds : tuple of str + Edge kinds to include, e.g. ``("calls", "imports")``. + + Returns + ------- + list of (str, str, str) + ``(source_file_path, target_file_path, edge_kind)`` per matching edge. + """ + if not kinds: + return [] + placeholders = ",".join("?" * len(kinds)) + rows = self._conn.execute( + "SELECT s.file_path AS src, t.file_path AS tgt, e.kind AS k " + "FROM edges e " + "JOIN nodes s ON s.id = e.source " + "JOIN nodes t ON t.id = e.target " + f"WHERE e.kind IN ({placeholders})", + kinds, + ).fetchall() + return [(r["src"], r["tgt"], r["k"]) for r in rows] + + def definition_ranges(self) -> list[tuple[str, int, int]]: + """The ``(file_path, start_line, end_line)`` of every definable symbol. + + "Definable" means a ``function``, ``method``, or ``class`` — the symbols + a rationale card can be generated for. The Phase-2 coverage aggregation + (:mod:`whygraph.serve.coverage`) joins these line ranges against the + ``rationale_cache`` to compute per-file/dir "analyzed" fractions. + + Returns + ------- + list of (str, int, int) + One tuple per definable symbol. + """ + placeholders = ",".join("?" * len(_DEFINABLE_KINDS)) + rows = self._conn.execute( + f"SELECT file_path, start_line, end_line FROM nodes " + f"WHERE kind IN ({placeholders})", + _DEFINABLE_KINDS, + ).fetchall() + return [ + (r["file_path"], int(r["start_line"]), int(r["end_line"])) for r in rows + ] + def neighbors(self, node_id: str, depth: int = 1) -> list[Symbol]: """Walk outward from a symbol over edges of any kind. diff --git a/tests/test_serve_api.py b/tests/test_serve_api.py new file mode 100644 index 0000000..8d7f36a --- /dev/null +++ b/tests/test_serve_api.py @@ -0,0 +1,324 @@ +"""Integration tests for the Explorer HTTP API — :mod:`whygraph.serve`. + +Each test drives a FastAPI ``TestClient`` over :func:`create_app`, backed by a fake +CodeGraph DB (a ``file → class → method`` tree plus a caller) and an initialised, +empty WhyGraph DB. The rationale-split tests monkeypatch the service functions so +they can assert the LLM path is taken **only** on ``POST`` — never on a passive +``GET`` — which is the whole point of the resolved Q3 design. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest import mock + +import pytest +from fastapi.testclient import TestClient + +from whygraph import core +from whygraph.core.config import Config +from whygraph.db import engine as db_engine +from whygraph.serve import routes +from whygraph.serve.app import create_app + +# A small graph: file a.py contains class A contains method m; b.py's `caller` +# calls m and imports A. +_NODES = [ + { + "id": "n_file_a", + "kind": "file", + "name": "a.py", + "qualified_name": "src/pkg/a.py", + "file_path": "src/pkg/a.py", + "language": "python", + "start_line": 1, + "end_line": 40, + "docstring": None, + "signature": None, + }, + { + "id": "n_cls", + "kind": "class", + "name": "A", + "qualified_name": "pkg.a.A", + "file_path": "src/pkg/a.py", + "language": "python", + "start_line": 3, + "end_line": 30, + "docstring": None, + "signature": "class A", + }, + { + "id": "n_m", + "kind": "method", + "name": "m", + "qualified_name": "pkg.a.A.m", + "file_path": "src/pkg/a.py", + "language": "python", + "start_line": 5, + "end_line": 10, + "docstring": "does m", + "signature": "def m(self)", + }, + { + "id": "n_file_b", + "kind": "file", + "name": "b.py", + "qualified_name": "src/pkg/b.py", + "file_path": "src/pkg/b.py", + "language": "python", + "start_line": 1, + "end_line": 20, + "docstring": None, + "signature": None, + }, + { + "id": "n_caller", + "kind": "function", + "name": "caller", + "qualified_name": "pkg.b.caller", + "file_path": "src/pkg/b.py", + "language": "python", + "start_line": 2, + "end_line": 8, + "docstring": None, + "signature": "def caller()", + }, +] +_EDGES = [ + ("n_file_a", "n_cls", "contains"), + ("n_cls", "n_m", "contains"), + ("n_caller", "n_m", "calls"), + ("n_caller", "n_cls", "imports"), +] + + +@pytest.fixture +def serve_client(tmp_path, monkeypatch, codegraph_db_factory): + """A TestClient over ``create_app``, with a fake CodeGraph + empty WhyGraph DB. + + Points the app's static dir at an empty path so the API tests are independent + of whether ``make playground`` has been run (the built bundle is gitignored). + """ + cg_path = codegraph_db_factory(nodes=_NODES, edges=_EDGES) + wdb = tmp_path / "whygraph.db" + monkeypatch.setattr(core, "_config", Config(whygraph_db=wdb, codegraph_db=cg_path)) + monkeypatch.setattr("whygraph.serve.app._STATIC_DIR", tmp_path / "nostatic") + db_engine._reset_engine() + try: + with TestClient(create_app(core._config)) as client: + yield client + finally: + db_engine._reset_engine() + core._reset_config() + + +# ---- tree ---------------------------------------------------------------- + + +def test_tree_root_lists_top_directory(serve_client) -> None: + entries = serve_client.get("/api/tree").json()["entries"] + assert [e["label"] for e in entries] == ["src"] + assert entries[0]["kind"] == "directory" + assert entries[0]["dir"] == "src" + + +def test_tree_directory_lists_files(serve_client) -> None: + entries = serve_client.get("/api/tree", params={"dir": "src/pkg"}).json()["entries"] + labels = {e["label"] for e in entries} + assert labels == {"a.py", "b.py"} + assert all(e["kind"] == "file" for e in entries) + + +def test_tree_node_lists_symbol_children(serve_client) -> None: + entries = serve_client.get("/api/tree", params={"node": "n_file_a"}).json()[ + "entries" + ] + assert [e["qualified_name"] for e in entries] == ["pkg.a.A"] + + +# ---- search -------------------------------------------------------------- + + +def test_search_finds_symbol_with_coverage_flag(serve_client) -> None: + results = serve_client.get("/api/search", params={"q": "A.m"}).json()["results"] + assert any(r["qualified_name"] == "pkg.a.A.m" for r in results) + assert all(r["analyzed"] is False for r in results) # nothing cached yet + + +def test_search_empty_query_returns_no_results(serve_client) -> None: + assert serve_client.get("/api/search", params={"q": ""}).json()["results"] == [] + + +# ---- ego graph ----------------------------------------------------------- + + +def test_ego_graph_has_focus_neighbours_and_coords(serve_client) -> None: + body = serve_client.get( + "/api/graph/ego", params={"qualified_name": "pkg.a.A.m"} + ).json() + assert body["focus"] == "pkg.a.A.m" + ids = {n["id"] for n in body["nodes"]} + assert ids == {"n_m", "n_caller", "n_cls"} # focus + caller + container + focus = next(n for n in body["nodes"] if n["data"]["is_focus"]) + assert focus["position"] == {"x": 0.0, "y": 0.0} + edge_kinds = {(e["source"], e["target"], e["kind"]) for e in body["edges"]} + assert ("n_caller", "n_m", "calls") in edge_kinds + assert ("n_cls", "n_m", "contains") in edge_kinds + + +def test_ego_graph_404_for_unknown_symbol(serve_client) -> None: + r = serve_client.get("/api/graph/ego", params={"qualified_name": "pkg.nope"}) + assert r.status_code == 404 + + +def test_overview_lifts_to_directory_supernode(serve_client) -> None: + # Nothing expanded → both files collapse into the top-level `src` super-node. + body = serve_client.get("/api/graph/overview").json() + assert {n["id"] for n in body["nodes"]} == {"dir:src"} + assert all("coverage" in n for n in body["nodes"]) + + +def test_overview_expanded_reveals_files(serve_client) -> None: + body = serve_client.get( + "/api/graph/overview", params={"expanded": "src,src/pkg"} + ).json() + ids = {n["id"] for n in body["nodes"]} + assert "file:src/pkg/a.py" in ids and "file:src/pkg/b.py" in ids + # b.py's caller calls/imports into a.py → a directional lifted edge exists. + assert any( + e["source"] == "file:src/pkg/b.py" and e["target"] == "file:src/pkg/a.py" + for e in body["edges"] + ) + + +# ---- node detail --------------------------------------------------------- + + +def test_node_detail_groups_relations(serve_client) -> None: + body = serve_client.get("/api/node/pkg.a.A.m").json() + assert body["symbol"]["qualified_name"] == "pkg.a.A.m" + rel = body["relations"] + assert [c["qualified_name"] for c in rel["callers"]] == ["pkg.b.caller"] + assert rel["container"]["qualified_name"] == "pkg.a.A" + assert body["analyzed"] is False + + +def test_node_detail_404_for_unknown(serve_client) -> None: + assert serve_client.get("/api/node/pkg.nope").status_code == 404 + + +# ---- rationale split (the resolved Q3 design) ---------------------------- + + +def _fake_evidence() -> SimpleNamespace: + return SimpleNamespace(pull_requests=[], issues=[]) + + +def test_rationale_get_no_evidence_makes_no_llm_call(serve_client, monkeypatch) -> None: + monkeypatch.setattr(routes, "collect_evidence", lambda target, limit=20: []) + gen = mock.Mock() + monkeypatch.setattr(routes, "whygraph_rationale_brief", gen) + + body = serve_client.get("/api/node/pkg.a.A.m/rationale").json() + + assert body["status"] == "no_evidence" + gen.assert_not_called() + + +def test_rationale_get_not_generated_makes_no_llm_call( + serve_client, monkeypatch +) -> None: + monkeypatch.setattr( + routes, "collect_evidence", lambda t, limit=20: [_fake_evidence()] + ) + monkeypatch.setattr(routes, "lookup_cached", lambda *a, **k: None) + gen = mock.Mock() + monkeypatch.setattr(routes, "whygraph_rationale_brief", gen) + + body = serve_client.get("/api/node/pkg.a.A.m/rationale").json() + + assert body["status"] == "not_generated" + gen.assert_not_called() + + +def test_rationale_get_returns_cached_card(serve_client, monkeypatch) -> None: + from whygraph.analyze import Rationale + + rationale = Rationale( + purpose="the purpose", + why="the why", + constraints=("c1",), + tradeoffs=(), + risks=(), + model="test-model", + provider="test", + input_tokens=1, + output_tokens=2, + ) + monkeypatch.setattr( + routes, "collect_evidence", lambda t, limit=20: [_fake_evidence()] + ) + monkeypatch.setattr( + routes, + "lookup_cached", + lambda *a, **k: (rationale, "2026-01-01T00:00:00+00:00"), + ) + gen = mock.Mock() + monkeypatch.setattr(routes, "whygraph_rationale_brief", gen) + + body = serve_client.get("/api/node/pkg.a.A.m/rationale").json() + + assert body["status"] == "cached" + assert body["purpose"] == "the purpose" + assert body["constraints"] == ["c1"] + gen.assert_not_called() # cache read is still LLM-free + + +def test_rationale_post_calls_brief_verbatim(serve_client, monkeypatch) -> None: + card = { + "target": {"path": "src/pkg/a.py", "line_start": 5, "line_end": 10}, + "purpose": "generated purpose", + } + gen = mock.Mock(return_value=card) + monkeypatch.setattr(routes, "whygraph_rationale_brief", gen) + + body = serve_client.post("/api/node/pkg.a.A.m/rationale").json() + + assert body["status"] == "cached" + assert body["purpose"] == "generated purpose" + gen.assert_called_once_with(qualified_name="pkg.a.A.m") + + +# ---- static fallback ----------------------------------------------------- + + +def test_root_reports_ui_not_built(serve_client) -> None: + # No static bundle in a source checkout — the API must still serve, and `/` + # returns the guidance message rather than 500. + r = serve_client.get("/") + assert r.status_code == 200 + assert "ui is not built" in r.text.lower() + assert "make playground" in r.text + + +def test_serves_spa_when_built(tmp_path, monkeypatch, codegraph_db_factory) -> None: + # With a built bundle, `/` serves index.html, unknown client routes fall back + # to it (SPA routing), and /api still wins over the catch-all. + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("WG-BUILT") + monkeypatch.setattr("whygraph.serve.app._STATIC_DIR", static) + cg_path = codegraph_db_factory(nodes=_NODES, edges=_EDGES) + monkeypatch.setattr( + core, "_config", Config(whygraph_db=tmp_path / "w.db", codegraph_db=cg_path) + ) + db_engine._reset_engine() + try: + with TestClient(create_app(core._config)) as client: + assert "WG-BUILT" in client.get("/").text + assert "WG-BUILT" in client.get("/some/client/route").text + assert client.get("/api/tree").status_code == 200 + finally: + db_engine._reset_engine() + core._reset_config() diff --git a/tests/test_serve_phase2.py b/tests/test_serve_phase2.py new file mode 100644 index 0000000..4dbbbd7 --- /dev/null +++ b/tests/test_serve_phase2.py @@ -0,0 +1,145 @@ +"""Tests for the Phase-2 LOD overview — edge-lifting + coverage. + +The lifting tests exercise the three cases from §8.1 (internal / cross-container / +mixed expansion) against a hand-built graph: three files in two directories with +two ``calls`` edges. Coverage is tested against a seeded ``rationale_cache``. +""" + +from __future__ import annotations + +import pytest + +from whygraph.services.codegraph import CodeGraph +from whygraph.serve import coverage, lifting + + +def _node( + nid: str, kind: str, name: str, file_path: str, start: int = 1, end: int = 5 +) -> dict: + return { + "id": nid, + "kind": kind, + "name": name, + "qualified_name": name, + "file_path": file_path, + "language": "python", + "start_line": start, + "end_line": end, + "docstring": None, + "signature": None, + } + + +# src/a/foo.py::foo calls src/b/bar.py::bar (cross dir) and src/a/baz.py::baz (same dir). +_NODES = [ + _node("file:src/a/foo.py", "file", "foo.py", "src/a/foo.py"), + _node("file:src/a/baz.py", "file", "baz.py", "src/a/baz.py"), + _node("file:src/b/bar.py", "file", "bar.py", "src/b/bar.py"), + _node("fn_foo", "function", "foo", "src/a/foo.py", 1, 10), + _node("fn_baz", "function", "baz", "src/a/baz.py", 1, 10), + _node("fn_bar", "function", "bar", "src/b/bar.py", 1, 10), +] +_EDGES = [ + ("fn_foo", "fn_bar", "calls"), + ("fn_foo", "fn_baz", "calls"), +] + + +@pytest.fixture +def overview_db(codegraph_db_factory): + return codegraph_db_factory(nodes=_NODES, edges=_EDGES) + + +def _overview(db, expanded: set[str], cov=None): + with CodeGraph(db) as graph: + return lifting.build_overview(graph, expanded, cov or {}) + + +def test_lifting_internal_when_nothing_expanded(overview_db) -> None: + # Both edges collapse into the single top-level `dir:src` super-node → hidden. + ov = _overview(overview_db, set()) + assert {n["id"] for n in ov["nodes"]} == {"dir:src"} + assert ov["edges"] == [] + assert ov["nodes"][0]["internal_edges"] == 2 + + +def test_lifting_cross_container_edge(overview_db) -> None: + # Expand `src`: foo/baz roll up to dir:src/a, bar to dir:src/b. + ov = _overview(overview_db, {"src"}) + ids = {n["id"] for n in ov["nodes"]} + assert ids == {"dir:src/a", "dir:src/b"} + assert ov["edges"] == [ + { + "id": "dir:src/a->dir:src/b:calls", + "source": "dir:src/a", + "target": "dir:src/b", + "kind": "calls", + "weight": 1, + } + ] + # foo→baz is internal to dir:src/a. + src_a = next(n for n in ov["nodes"] if n["id"] == "dir:src/a") + assert src_a["internal_edges"] == 1 + + +def test_lifting_mixed_expansion(overview_db) -> None: + # Expand `src` and `src/a`: foo/baz become file nodes; bar stays dir:src/b. + ov = _overview(overview_db, {"src", "src/a"}) + ids = {n["id"] for n in ov["nodes"]} + assert ids == {"file:src/a/foo.py", "file:src/a/baz.py", "dir:src/b"} + edge_tuples = {(e["source"], e["target"], e["weight"]) for e in ov["edges"]} + assert ("file:src/a/foo.py", "dir:src/b", 1) in edge_tuples # mixed + assert ("file:src/a/foo.py", "file:src/a/baz.py", 1) in edge_tuples # cross file + + +def test_lifting_edges_are_directional(overview_db) -> None: + # X→Y and Y→X must never collapse into one undirected edge. + ov = _overview(overview_db, {"src"}) + dirs = {(e["source"], e["target"]) for e in ov["edges"]} + assert ("dir:src/a", "dir:src/b") in dirs # foo(src/a) → bar(src/b) + assert ("dir:src/b", "dir:src/a") not in dirs # no reverse edge exists + + +def test_coverage_counts_analyzed_over_total( + overview_db, whygraph_db_initialized +) -> None: + from whygraph.db import get_session + from whygraph.db.models import RationaleCache + + # Seed one cached rationale that matches fn_foo's (path, line range). + with get_session() as session: + session.add( + RationaleCache( + path="src/a/foo.py", + line_start=1, + line_end=10, + provider="test", + model="default", + evidence_fingerprint="fp", + cached_at="2026-01-01T00:00:00+00:00", + purpose="p", + why="w", + constraints="[]", + tradeoffs="[]", + risks="[]", + ) + ) + session.commit() + + with CodeGraph(overview_db) as graph: + cov = coverage.file_coverage(graph) + + assert cov["src/a/foo.py"] == (1, 1) # analyzed + assert cov["src/a/baz.py"] == (0, 1) # not analyzed + assert cov["src/b/bar.py"] == (0, 1) + + +def test_coverage_feeds_overview_node(overview_db) -> None: + ov = _overview( + overview_db, {"src"}, cov={"src/a/foo.py": (1, 2), "src/a/baz.py": (0, 1)} + ) + src_a = next(n for n in ov["nodes"] if n["id"] == "dir:src/a") + # dir:src/a aggregates foo (1/2) + baz (0/1) = 1/3. + assert src_a["coverage"]["analyzed"] == 1 + assert src_a["coverage"]["total"] == 3 + assert src_a["coverage"]["fraction"] == pytest.approx(1 / 3) diff --git a/tests/test_services_codegraph.py b/tests/test_services_codegraph.py index d999d65..c3ec583 100644 --- a/tests/test_services_codegraph.py +++ b/tests/test_services_codegraph.py @@ -229,6 +229,86 @@ def test_neighbors_caps_at_max_depth(codegraph_db_factory) -> None: assert "n_e" not in ids +# ---- relations / imports_ / container / children / files ----------------- + + +def _tree_nodes() -> list[dict]: + """A file → class → method containment tree plus an imported module.""" + specs = [ + ("n_file", "file", "a.py", "src/pkg/a.py"), + ("n_cls", "class", "A", "pkg.a.A"), + ("n_m", "method", "m", "pkg.a.A.m"), + ("n_mod", "file", "b.py", "src/pkg/b.py"), + ] + return [ + { + "id": nid, + "kind": kind, + "name": name, + "qualified_name": qname, + "file_path": "src/pkg/a.py" if kind != "file" else qname, + "language": "python", + "start_line": 1, + "end_line": 5, + "docstring": None, + "signature": None, + } + for nid, kind, name, qname in specs + ] + + +def test_relations_filters_by_edge_kind(codegraph_db_factory) -> None: + path = codegraph_db_factory( + edges=[("n_a", "n_b", "imports"), ("n_a", "n_b", "calls")], + ) + with CodeGraph(path) as graph: + outgoing = graph.relations("n_a", "imports", incoming=False) + + assert [r.symbol.id for r in outgoing] == ["n_b"] + assert [r.kind for r in outgoing] == ["imports"] + + +def test_imports_returns_outgoing_import_edges(codegraph_db_factory) -> None: + path = codegraph_db_factory( + edges=[("n_a", "n_b", "imports"), ("n_b", "n_c", "calls")], + ) + with CodeGraph(path) as graph: + imports = graph.imports_("n_a") + assert [r.symbol.id for r in imports] == ["n_b"] + assert graph.imports_("n_c") == [] # only `calls` fan-in, no imports + + +def test_container_returns_incoming_contains_parent(codegraph_db_factory) -> None: + path = codegraph_db_factory( + nodes=_tree_nodes(), + edges=[("n_file", "n_cls", "contains"), ("n_cls", "n_m", "contains")], + ) + with CodeGraph(path) as graph: + assert graph.container("n_m").id == "n_cls" + assert graph.container("n_cls").id == "n_file" + assert graph.container("n_file") is None + + +def test_children_returns_outgoing_contains_members(codegraph_db_factory) -> None: + path = codegraph_db_factory( + nodes=_tree_nodes(), + edges=[("n_file", "n_cls", "contains"), ("n_cls", "n_m", "contains")], + ) + with CodeGraph(path) as graph: + assert [s.id for s in graph.children("n_file")] == ["n_cls"] + assert [s.id for s in graph.children("n_cls")] == ["n_m"] + assert graph.children("n_m") == [] + + +def test_files_returns_file_nodes_ordered_by_path(codegraph_db_factory) -> None: + path = codegraph_db_factory(nodes=_tree_nodes()) + with CodeGraph(path) as graph: + files = graph.files() + + assert [s.id for s in files] == ["n_file", "n_mod"] + assert all(s.kind == "file" for s in files) + + # ---- context ------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 7dde668..5ad3079 100644 --- a/uv.lock +++ b/uv.lock @@ -413,6 +413,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -2106,6 +2122,7 @@ dependencies = [ { name = "alembic" }, { name = "anthropic" }, { name = "click" }, + { name = "fastapi" }, { name = "mcp", extra = ["cli"] }, { name = "ollama" }, { name = "openai" }, @@ -2114,6 +2131,7 @@ dependencies = [ { name = "scikit-learn" }, { name = "sqlmodel" }, { name = "tomli-w" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -2131,6 +2149,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.13,<2" }, { name = "anthropic", specifier = ">=0.40" }, { name = "click", specifier = ">=8.1" }, + { name = "fastapi", specifier = ">=0.110" }, { name = "mcp", extras = ["cli"], specifier = ">=1.2" }, { name = "ollama", specifier = ">=0.3" }, { name = "openai", specifier = ">=1.40" }, @@ -2139,6 +2158,7 @@ requires-dist = [ { name = "scikit-learn", specifier = ">=1.3" }, { name = "sqlmodel", specifier = ">=0.0.22" }, { name = "tomli-w", specifier = ">=1.0" }, + { name = "uvicorn", specifier = ">=0.27" }, ] [package.metadata.requires-dev] From 2bb00db554d789e0d235b6164877edfb6495f9e1 Mon Sep 17 00:00:00 2001 From: cvetty Date: Fri, 24 Jul 2026 18:22:43 +0300 Subject: [PATCH 2/3] docs: document the Explorer playground (`whygraph serve`) - New User Guide page: what the panel is, how to run it, the container lifecycle (--detach/--stop/--logs, WHYGRAPH_PORT), the tree/graph/detail layout, button-triggered rationale + the "scan first" caveat, the coverage heatmap, and the `make dev` HMR loop. - Add it to the mkdocs nav and a card on the User Guide + Quickstart index. - CLI reference: add the `whygraph serve` command (it was missing) with its --port/--host flags and the shim-level lifecycle verbs; fix the count. - Short mentions in README and the Quickstart. --- README.md | 1 + docs/getting-started/quickstart.md | 13 ++++ docs/guide/index.md | 8 ++ docs/guide/playground.md | 118 +++++++++++++++++++++++++++++ docs/reference/cli.md | 25 +++++- mkdocs.yml | 1 + 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 docs/guide/playground.md diff --git a/README.md b/README.md index 13da8f7..562ba54 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ whygraph init # bootstrap the WhyGraph DB + write config whygraph scan # crawl history + refresh CodeGraph + LLM descriptions whygraph init --agent claude # wire the MCP server into your editor whygraph-mcp # sanity-check the server (Ctrl-C to exit) +whygraph serve # browse the graph, evidence + rationale in a local web panel ``` The only-Docker install needs nothing but Docker on the host — one command pulls the image and diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index a454ef8..bc43979 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -45,6 +45,11 @@ whygraph scan --no-remote --skip-analyze Descriptions backfill lazily later, so this is a fine way to get started quickly. See [Scanning your repo](../guide/scanning.md) for what each phase does. +!!! tip "Prefer a visual view?" + Once you've scanned, `whygraph serve` opens a local, read-only web panel over the graph, evidence, + and rationale - browse it in the browser instead of (or alongside) your editor. See + [The Explorer playground](../guide/playground.md). + ## 3. Wire your editor Register the MCP server with your agent. For Claude Code: @@ -86,4 +91,12 @@ function exists, and WhyGraph answers from history. [:octicons-arrow-right-24: MCP usage](../guide/mcp-usage.md) +- :material-graph-outline:{ .lg .middle } __Explorer playground__ + + --- + + Browse the graph, evidence, and rationale in a local web panel. + + [:octicons-arrow-right-24: Playground](../guide/playground.md) +
diff --git a/docs/guide/index.md b/docs/guide/index.md index 8a1ee6b..88031f8 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -43,4 +43,12 @@ Start with the concepts, then dig into whichever piece you need. [:octicons-arrow-right-24: MCP usage](mcp-usage.md) +- :material-graph-outline:{ .lg .middle } __Explorer playground__ + + --- + + A local, read-only web panel over the graph, evidence, and rationale. + + [:octicons-arrow-right-24: Playground](playground.md) +
diff --git a/docs/guide/playground.md b/docs/guide/playground.md new file mode 100644 index 0000000..2416308 --- /dev/null +++ b/docs/guide/playground.md @@ -0,0 +1,118 @@ +# The Explorer playground + +`whygraph serve` opens a local, **read-only** web panel onto everything WhyGraph and CodeGraph have +built for the current repo: browse the code graph, jump to any symbol, and read its rationale, +evidence, relationships, and history side by side. It's the same data the MCP tools serve - the web +API is just a second transport over the exact same functions, so the panel can never drift from what +your editor sees. + +It runs from the **same Docker image** as every other command, as its own long-lived container - no +second image, no extra install. + +## Run it + +From a scanned repo: + +```bash +whygraph serve +``` + +That starts the server in the foreground and prints a URL - open . `Ctrl-C` +stops it. + +!!! note "Scan first" + The panel reads the CodeGraph index and the WhyGraph evidence database. Run + [`whygraph scan`](scanning.md) at least once before serving - otherwise there's no graph to draw, + and every symbol's rationale shows *"no evidence"* (see [Rationale on demand](#rationale-on-demand)). + +### Lifecycle + +On the Docker install the shim manages the container for you: + +| Command | What it does | +|---|---| +| `whygraph serve` | Run in the foreground; `Ctrl-C` stops and removes the container. | +| `whygraph serve --detach` | Start in the background and return immediately. | +| `whygraph serve --logs` | Tail the detached server's logs. | +| `whygraph serve --stop` | Stop and remove the running server. | + +The port is controlled by the `WHYGRAPH_PORT` environment variable (default `8765`): + +```bash +WHYGRAPH_PORT=9000 whygraph serve --detach +``` + +!!! info "Localhost only" + The server is published to `127.0.0.1` only - it's a single-user local dev tool with **no auth**. + Nothing is exposed beyond your machine's loopback. The only action that writes anything is the + explicit **Generate rationale** button; everything else is read-only. + +## What you see + +
+ +- __Left - containment tree__ + + --- + + `directory → file → class → method`, lazy-loaded. Click a symbol to open it. + +- __Center - graph__ + + --- + + The **overview** (directory super-nodes, colored by rationale coverage) is the landing view; + click a directory to expand it. Pick a symbol and the center switches to its **ego graph** - + what it calls, is called by, imports, and contains. + +- __Right - detail panel__ + + --- + + Tabs for **Relationships**, **Rationale**, **Evidence**, and **History** on the selected symbol. + +- __⌘K - search__ + + --- + + Find any symbol by name (disambiguated by file path), `Enter` to open it - recentering the + graph, opening the panel, and revealing it in the tree. + +
+ +Every symbol reference in the panel - a search hit, a graph node, a relationship row - opens the same +way, so you can navigate the codebase by following edges. + +### Rationale on demand + +Generating a rationale card calls an LLM, so the panel never does it behind your back. The +**Rationale** tab shows a cached card if one exists; otherwise it shows a **Generate rationale** +button. Click it, watch the loading state, and the card renders - and is cached, exactly as if the +MCP tool had produced it. + +The button is **disabled** when the symbol has no historical evidence to reason from - most commonly +because the repo hasn't been scanned, or the code isn't committed yet. Run `whygraph scan` and the +button lights up. The **Evidence** and **History** tabs never call an LLM, so they always work. + +### Coverage heatmap + +Because rationale cards are generated lazily, the overview colors each directory and file by how much +of it has been analyzed - a quick map of where you've already asked "why?" and where you haven't. + +## Develop the UI + +The panel's source lives at `src/playground/` (Vite + React + TypeScript). For a hot-reloading dev +loop - the backend on `:8765` and the Vite dev server on `:5173`, proxying the API across: + +```bash +make dev # backend + Vite HMR together; Ctrl-C stops both; open :5173 +``` + +Other targets: `make playground` builds the production bundle into the wheel's static directory, and +`make serve` builds it then serves it the way it ships. All need Node ≥ 18 (`nvm use 22`). + +## Not in scope + +The panel is deliberately narrow: **no chat/assistant tab** (that needs model config and an auth +story), **no writes** other than the Generate button, and **no remote hosting**. See the +[roadmap](../roadmap.md) for what's deferred. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8b2c8e7..4af1195 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,7 +1,7 @@ # CLI reference Every WhyGraph command and its flags. Run `whygraph --help` to see the same text from your -own install. There are five commands. +own install. There are six commands. ```console $ whygraph --help @@ -10,6 +10,7 @@ Commands: hooks Manage opt-in git hooks that auto-rescan on new commits. init Initialize the WhyGraph database under .whygraph/whygraph.db. scan Run the source crawlers, then describe each commit with the LLM. + serve Serve the WhyGraph Explorer panel for this repository. version Print installed whygraph version. ``` @@ -65,6 +66,28 @@ picks up new commits and backfills what's missing. See [Scanning your repo](../guide/scanning.md) for what each phase does. +## `whygraph serve` + +Serve the read-only **Explorer playground** for this repository - a local web panel over the code +graph, evidence, and rationale. On the Docker install it runs as its own long-lived container, published +to `127.0.0.1` only. Run `whygraph scan` first so there's an index and evidence to show. + +| Option | Default | Description | +|---|---|---| +| `--port` | `8765` | Port to bind. On the Docker install, set the port via the `WHYGRAPH_PORT` environment variable instead (the shim controls both the published and in-container port). | +| `--host` | `127.0.0.1` | Bind address. The Docker shim passes `0.0.0.0` for the container so the loopback port-forward can reach it; you rarely set this by hand. | + +On the Docker install the shim also adds container-lifecycle verbs - these are **not** flags of the +Python command, they're handled on the host before the container starts: + +| Command | What it does | +|---|---| +| `whygraph serve --detach` | Start in the background and return immediately. | +| `whygraph serve --logs` | Tail the detached server's logs. | +| `whygraph serve --stop` | Stop and remove the running server. | + +See [The Explorer playground](../guide/playground.md) for the panel itself. + ## `whygraph analyze` Describe a single commit's diff with the configured LLM and **print** the result. Unlike `scan`, it diff --git a/mkdocs.yml b/mkdocs.yml index f492822..6954ea1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,6 +86,7 @@ nav: - Scanning your repo: guide/scanning.md - Wiring your editor: guide/editors.md - Using WhyGraph (MCP): guide/mcp-usage.md + - Explorer playground: guide/playground.md - Docker & Self-Hosting: - deploy/index.md - Run with Docker: deploy/docker.md From 2e2889c0c7cbbfc8a714fdcc9a717ccf7a761909 Mon Sep 17 00:00:00 2001 From: cvetty Date: Fri, 24 Jul 2026 20:06:24 +0300 Subject: [PATCH 3/3] docs(playground): note rationale generation uses the whygraph.toml LLM config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that the Generate button uses the [rationale] provider + [llm.] api_key from whygraph.toml (what `whygraph init` sets up), with the provider env var only as a fallback — not an env-first requirement. --- docs/guide/playground.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/guide/playground.md b/docs/guide/playground.md index 2416308..c4d8277 100644 --- a/docs/guide/playground.md +++ b/docs/guide/playground.md @@ -94,6 +94,12 @@ The button is **disabled** when the symbol has no historical evidence to reason because the repo hasn't been scanned, or the code isn't committed yet. Run `whygraph scan` and the button lights up. The **Evidence** and **History** tabs never call an LLM, so they always work. +Generation uses the rationale LLM you configured in `whygraph.toml` - `[rationale] provider` and the +matching `[llm.]` (with its `api_key`), exactly as `whygraph init` sets it up and the same +provider the MCP tool uses. If you leave `api_key` unset, the provider's conventional env var (e.g. +`ANTHROPIC_API_KEY`) is the fallback; the Docker container reads your repo's `whygraph.toml` directly. +See [Configuration](../reference/configuration.md). + ### Coverage heatmap Because rationale cards are generated lazily, the overview colors each directory and file by how much